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: "d".repeat(64),399 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),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 const existing = await findPairedDeviceById("android-existing");431 expect(existing).toMatchObject({432 deviceName: "Existing Pixel",433 longTermPublicKeyHex: "d".repeat(64),434 trustSource: "direct",435 wsPort: 52345,436 });437 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([438 "android-existing",439 "android-new",440 "extension-issuer",441 ]);442 });443444 it("rejects manifest key replacement for an existing device", async () => {445 const { adapter } = await setupVault();446 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");447 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");448 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");449 const issuerId = "extension-issuer";450 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);451 await upsertPairedDevice({452 deviceId: issuerId,453 deviceName: "Firefox",454 deviceType: "extension",455 longTermPublicKeyHex: "c".repeat(64),456 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),457 pairedAt: 1,458 trustSource: "direct",459 });460 await upsertPairedDevice({461 deviceId: "android-existing",462 deviceName: "Pixel",463 deviceType: "android",464 longTermPublicKeyHex: "d".repeat(64),465 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),466 pairedAt: 2,467 trustSource: "direct",468 });469 const manifest = await buildSignedDeviceManifest(470 issuerId,471 [{472 deviceId: "android-existing",473 deviceName: "Pixel",474 deviceType: "android",475 longTermPublicKeyHex: "f".repeat(64),476 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),477 wsPort: 52345,478 pairedAt: 3,479 }],480 issuerKp.privateKey,481 );482 await expect(483 adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),484 ).rejects.toThrow("device_identity_immutable");485 await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({486 longTermPublicKeyHex: "d".repeat(64),487 });488 });489490 it("rejects manifest replacement even when the device name changes", async () => {491 const { adapter } = await setupVault();492 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");493 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");494 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");495 const issuerId = "hostile-issuer";496 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);497 await upsertPairedDevice({498 deviceId: issuerId,499 deviceName: "Old Laptop",500 deviceType: "extension",501 longTermPublicKeyHex: "c".repeat(64),502 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),503 pairedAt: 1,504 trustSource: "direct",505 });506 await upsertPairedDevice({507 deviceId: "android-existing",508 deviceName: "Spare Tablet",509 deviceType: "android",510 longTermPublicKeyHex: "d".repeat(64),511 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),512 pairedAt: 2,513 trustSource: "direct",514 });515 const manifest = await buildSignedDeviceManifest(516 issuerId,517 [{518 deviceId: "android-existing",519 deviceName: "June's Primary Phone",520 deviceType: "android",521 longTermPublicKeyHex: "f".repeat(64),522 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),523 wsPort: 52345,524 pairedAt: 3,525 }],526 issuerKp.privateKey,527 );528 await expect(529 adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),530 ).rejects.toThrow("device_identity_immutable");531 await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({532 deviceName: "Spare Tablet",533 longTermPublicKeyHex: "d".repeat(64),534 });535 });536537 it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {538 const { adapter } = await setupVault();539 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");540 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");541 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =542 await import("./paired-devices");543544 const issuerId = "phone-issuer";545 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);546 await upsertPairedDevice({547 deviceId: issuerId,548 deviceName: "Pixel",549 deviceType: "android",550 longTermPublicKeyHex: "c".repeat(64),551 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),552 pairedAt: 1,553 trustSource: "vouched",554 });555 await upsertPairedDevice({556 deviceId: "stolen-laptop",557 deviceName: "Old Laptop",558 deviceType: "extension",559 longTermPublicKeyHex: "d".repeat(64),560 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),561 pairedAt: 2,562 trustSource: "direct",563 });564565 const revocation = await buildSignedDeviceManifest(566 issuerId,567 [],568 issuerKp.privateKey,569 {570 revoked: [571 {572 deviceId: "stolen-laptop",573 longTermPublicKeyHex: "d".repeat(64),574 revokedAt: Date.now(),575 revokedByDeviceId: issuerId,576 },577 ],578 },579 );580 await adapter.applyIncomingDeviceManifest?.(581 revocation,582 new Uint8Array(32).fill(0xcc),583 );584585 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();586 expect(await isDeviceRevoked("stolen-laptop")).toBe(true);587588 589 590 591 const revouch = await buildSignedDeviceManifest(592 issuerId,593 [594 {595 deviceId: "stolen-laptop",596 deviceName: "Old Laptop",597 deviceType: "extension",598 longTermPublicKeyHex: "d".repeat(64),599 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),600 pairedAt: Date.now() + 60_000,601 },602 ],603 issuerKp.privateKey,604 );605 await adapter.applyIncomingDeviceManifest?.(606 revouch,607 new Uint8Array(32).fill(0xcc),608 );609610 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();611 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");612 expect(await listPendingVouchedDevices()).toHaveLength(0);613 });614615 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {616 const { adapter } = await setupVault();617 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");618 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");619 const {620 isDeviceRevoked,621 revokeDeviceById,622 upsertPairedDevice,623 } = await import("./paired-devices");624 const { getOrCreateDeviceId } = await import("./device-identity");625626 const ourDeviceId = await getOrCreateDeviceId();627 const issuerId = "phone-issuer";628 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);629 await upsertPairedDevice({630 deviceId: issuerId,631 deviceName: "Pixel",632 deviceType: "android",633 longTermPublicKeyHex: "c".repeat(64),634 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),635 pairedAt: 1,636 trustSource: "direct",637 });638639 640 const selfRevoke = await buildSignedDeviceManifest(641 issuerId,642 [],643 issuerKp.privateKey,644 {645 revoked: [646 {647 deviceId: ourDeviceId,648 longTermPublicKeyHex: "e".repeat(64),649 revokedAt: Date.now(),650 revokedByDeviceId: issuerId,651 },652 ],653 },654 );655 await adapter.applyIncomingDeviceManifest?.(656 selfRevoke,657 new Uint8Array(32).fill(0xcc),658 );659 expect(await isDeviceRevoked(ourDeviceId)).toBe(false);660661 662 663 await upsertPairedDevice({664 deviceId: "retired-tablet",665 deviceName: "Retired Tablet",666 deviceType: "android",667 longTermPublicKeyHex: "f".repeat(64),668 signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),669 pairedAt: 3,670 trustSource: "direct",671 });672 await revokeDeviceById("retired-tablet", ourDeviceId);673674 const outgoing = await adapter.buildOutgoingDeviceManifest?.();675 expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);676 expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");677 });678679 680 681 682 683 684 it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {685 const { adapter } = await setupVault();686 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");687 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");688 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");689 const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");690691 const issuerId = "android-legacy-issuer";692 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);693 await upsertPairedDevice({694 deviceId: issuerId,695 deviceName: "Issuer Pixel",696 deviceType: "android",697 longTermPublicKeyHex: "1".repeat(64),698 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),699 pairedAt: 1_700_000_000_000,700 trustSource: "direct",701 });702703 704 705 const legacyId = "legacy-peer";706 const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;707 registry.push({708 deviceId: legacyId,709 deviceName: "Legacy Peer",710 deviceType: "android",711 longTermPublicKeyHex: "3".repeat(64),712 signingPublicKeyHex: await signingPubkeyHex(legacyId),713 rendezvousSecretHex: "9".repeat(64),714 relayUrl: MESH_RELAY,715 pairedAt: 1_700_000_000_000,716 });717718 const manifest = await buildSignedDeviceManifest(719 issuerId,720 [{721 deviceId: legacyId,722 deviceName: "Legacy Peer",723 deviceType: "android",724 longTermPublicKeyHex: "3".repeat(64),725 signingPublicKeyHex: await signingPubkeyHex(legacyId),726 relayUrl: MESH_RELAY,727 pairedAt: 1_700_000_000_000,728 }],729 issuerKp.privateKey,730 );731 await adapter.applyIncomingDeviceManifest?.(732 manifest,733 new Uint8Array(32).fill(0x11),734 );735736 const row = await findPairedDeviceById(legacyId);737 expect(row).not.toBeNull();738 739 expect(row?.trustSource).toBeUndefined();740 expect(row?.trustSource).not.toBe("direct");741 742 expect(isRouteCapableMeshPeer(row!)).toBe(false);743 });744745 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {746 747 748 749 750 const { adapter } = await setupVault();751 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");752 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");753 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");754 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");755 const vault = await import("./vault");756757 const mobileIssuerId = "android-issuer";758 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);759 await upsertPairedDevice({760 deviceId: mobileIssuerId,761 deviceName: "Issuer Pixel",762 deviceType: "android",763 longTermPublicKeyHex: "1".repeat(64),764 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),765 pairedAt: 1_700_000_000_000,766 trustSource: "direct",767 });768 const mobileManifest = await buildSignedDeviceManifest(769 mobileIssuerId,770 [{771 deviceId: "mesh-peer",772 deviceName: "Other Pixel",773 deviceType: "android",774 longTermPublicKeyHex: "2".repeat(64),775 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),776 relayUrl: MESH_RELAY,777 pairedAt: 1_700_000_000_010,778 }],779 mobileIssuerKp.privateKey,780 );781 await adapter.applyIncomingDeviceManifest?.(782 mobileManifest,783 new Uint8Array(32).fill(0x11),784 );785786 expect(await findPairedDeviceById("mesh-peer")).toMatchObject({787 trustSource: "vouched",788 relayUrl: MESH_RELAY,789 });790 expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();791 const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");792 await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(793 /^[0-9a-f]{64}$/,794 );795 expect(await listPendingVouchedDevices()).toEqual([]);796 });797798 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {799 const { vault, adapter } = await setupVault();800 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");801 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");802 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");803 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");804805 806 807 808 const vouchedIssuerId = "android-vouched-issuer";809 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);810 await upsertPairedDevice({811 deviceId: vouchedIssuerId,812 deviceName: "Second-hand Pixel",813 deviceType: "android",814 longTermPublicKeyHex: "1".repeat(64),815 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),816 pairedAt: 1_700_000_000_000,817 trustSource: "vouched",818 });819 const onward = await buildSignedDeviceManifest(820 vouchedIssuerId,821 [{822 deviceId: "onward-mesh-peer",823 deviceName: "Other Pixel",824 deviceType: "android",825 longTermPublicKeyHex: "2".repeat(64),826 signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),827 relayUrl: MESH_RELAY,828 pairedAt: 1_700_000_000_010,829 }],830 vouchedKp.privateKey,831 );832 await adapter.applyIncomingDeviceManifest?.(833 onward,834 new Uint8Array(32).fill(0x11),835 );836 expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({837 trustSource: "vouched",838 relayUrl: MESH_RELAY,839 });840 expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();841 const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");842 await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(843 /^[0-9a-f]{64}$/,844 );845 expect(await listPendingVouchedDevices()).toEqual([]);846847 const extensionIssuerId = "extension-bad-sig";848 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);849 await upsertPairedDevice({850 deviceId: extensionIssuerId,851 deviceName: "Chrome",852 deviceType: "extension",853 longTermPublicKeyHex: "3".repeat(64),854 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),855 pairedAt: 1_700_000_000_020,856 trustSource: "direct",857 });858 const good = await buildSignedDeviceManifest(859 extensionIssuerId,860 [{861 deviceId: "forged-add",862 deviceName: "Forged Pixel",863 deviceType: "android",864 longTermPublicKeyHex: "4".repeat(64),865 signingPublicKeyHex: await signingPubkeyHex("forged-add"),866 pairedAt: 1_700_000_000_030,867 }],868 extensionKp.privateKey,869 );870 await adapter.applyIncomingDeviceManifest?.(871 good,872 new Uint8Array(32).fill(0x44),873 );874 expect(await findPairedDeviceById("forged-add")).toBeNull();875876 const forged = {877 ...good,878 signatureHex:879 good.signatureHex.slice(0, 2) +880 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +881 good.signatureHex.slice(4),882 };883 await adapter.applyIncomingDeviceManifest?.(884 forged,885 new Uint8Array(32).fill(0x33),886 );887 expect(await findPairedDeviceById("forged-add")).toBeNull();888 });889890 it("falls back to derived signing key when stored issuer key is stale", async () => {891 const { vault, adapter } = await setupVault();892 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");893 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");894 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");895 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");896897 const issuerId = "stale-key-android";898 const rootHex = vault.getRootKeyHex()!;899 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));900 const issuerKp = await deriveSyncSigningKeypair(root, issuerId);901902 await upsertPairedDevice({903 deviceId: issuerId,904 deviceName: "Pixel",905 deviceType: "android",906 longTermPublicKeyHex: "c".repeat(64),907 908 909 signingPublicKeyHex: "ff".repeat(32),910 pairedAt: 1_700_000_000_000,911 trustSource: "direct",912 });913914 const manifest = await buildSignedDeviceManifest(915 issuerId,916 [{917 deviceId: "onward-peer",918 deviceName: "Forwarded iPad",919 deviceType: "extension",920 longTermPublicKeyHex: "e".repeat(64),921 signingPublicKeyHex: pubkeyToHex(922 (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,923 ),924 relayUrl: MESH_RELAY,925 pairedAt: 1_700_000_000_010,926 }],927 issuerKp.privateKey,928 );929930 await adapter.applyIncomingDeviceManifest?.(931 manifest,932 new Uint8Array(32).fill(0xcc),933 );934935 expect(await findPairedDeviceById("onward-peer")).toMatchObject({936 trustSource: "vouched",937 relayUrl: MESH_RELAY,938 });939 expect((await findPairedDeviceById("onward-peer"))?.rendezvousSecretHex).toBeUndefined();940 const { getPairSecret: getPairSecretStale } = await import("./pair-secrets");941 await expect(getPairSecretStale("onward-peer", vault.getRootKeyHex())).resolves.toMatch(942 /^[0-9a-f]{64}$/,943 );944 expect(await listPendingVouchedDevices()).toEqual([]);945 });946947 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {948 const { adapter } = await setupVault();949 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");950 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");951 const { upsertPairedDevice } = await import("./paired-devices");952 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");953954 955 956 957 const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");958 const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");959 await upsertPairedDevice({960 deviceId: "peer-alpha",961 deviceName: "Alpha",962 deviceType: "android",963 longTermPublicKeyHex: "a".repeat(64),964 signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),965 pairedAt: 1,966 trustSource: "direct",967 });968 await upsertPairedDevice({969 deviceId: "peer-beta",970 deviceName: "Beta",971 deviceType: "android",972 longTermPublicKeyHex: "b".repeat(64),973 signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),974 pairedAt: 2,975 trustSource: "direct",976 });977978 const fromBeta = await buildSignedDeviceManifest(979 "peer-beta",980 [{981 deviceId: "smuggled",982 deviceName: "Smuggled",983 deviceType: "android",984 longTermPublicKeyHex: "5".repeat(64),985 signingPublicKeyHex: await signingPubkeyHex("smuggled"),986 relayUrl: MESH_RELAY,987 pairedAt: 3,988 }],989 betaKp.privateKey,990 );991992 993 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));994 expect(await listPendingVouchedDevices()).toEqual([]);995996 997 998 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));999 const { findPairedDeviceById } = await import("./paired-devices");1000 expect(await findPairedDeviceById("smuggled")).toMatchObject({1001 trustSource: "vouched",1002 relayUrl: MESH_RELAY,1003 });1004 expect(await listPendingVouchedDevices()).toEqual([]);1005 });10061007 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1008 const { adapter } = await setupVault();1009 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1010 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1011 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1012 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10131014 const issuerId = "phone-issuer";1015 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1016 const stolenKey = "d".repeat(64);1017 await upsertPairedDevice({1018 deviceId: issuerId,1019 deviceName: "Pixel",1020 deviceType: "android",1021 longTermPublicKeyHex: "c".repeat(64),1022 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1023 pairedAt: 1,1024 trustSource: "direct",1025 });1026 await upsertPairedDevice({1027 deviceId: "stolen-laptop",1028 deviceName: "Old Laptop",1029 deviceType: "extension",1030 longTermPublicKeyHex: stolenKey,1031 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1032 pairedAt: 2,1033 trustSource: "direct",1034 });10351036 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1037 revoked: [{1038 deviceId: "stolen-laptop",1039 longTermPublicKeyHex: stolenKey,1040 revokedAt: Date.now(),1041 revokedByDeviceId: issuerId,1042 }],1043 });1044 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1045 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10461047 1048 1049 const renamed = await buildSignedDeviceManifest(1050 issuerId,1051 [{1052 deviceId: "stolen-laptop-mk2",1053 deviceName: "New Laptop",1054 deviceType: "extension",1055 longTermPublicKeyHex: stolenKey,1056 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1057 relayUrl: MESH_RELAY,1058 pairedAt: Date.now(),1059 }],1060 issuerKp.privateKey,1061 );1062 await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1063 expect(await listPendingVouchedDevices()).toEqual([]);1064 expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10651066 1067 1068 const clean = await buildSignedDeviceManifest(1069 issuerId,1070 [{1071 deviceId: "stolen-laptop-mk2",1072 deviceName: "New Laptop",1073 deviceType: "extension",1074 longTermPublicKeyHex: "9".repeat(64),1075 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1076 relayUrl: MESH_RELAY,1077 pairedAt: Date.now(),1078 }],1079 issuerKp.privateKey,1080 );1081 await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1082 expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1083 trustSource: "vouched",1084 relayUrl: MESH_RELAY,1085 });1086 expect(await listPendingVouchedDevices()).toEqual([]);1087 });10881089 it("S1: a revoked key cannot be written onto a device we still trust", async () => {1090 const { adapter } = await setupVault();1091 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1092 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1093 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10941095 const issuerId = "phone-issuer";1096 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1097 const revokedKey = "d".repeat(64);1098 await upsertPairedDevice({1099 deviceId: issuerId,1100 deviceName: "Pixel",1101 deviceType: "android",1102 longTermPublicKeyHex: "c".repeat(64),1103 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1104 pairedAt: 1,1105 trustSource: "direct",1106 });1107 await upsertPairedDevice({1108 deviceId: "trusted-tablet",1109 deviceName: "Tablet",1110 deviceType: "android",1111 longTermPublicKeyHex: "7".repeat(64),1112 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1113 pairedAt: 2,1114 trustSource: "direct",1115 });11161117 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1118 revoked: [{1119 deviceId: "stolen-laptop",1120 longTermPublicKeyHex: revokedKey,1121 revokedAt: Date.now(),1122 revokedByDeviceId: issuerId,1123 }],1124 });1125 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11261127 1128 1129 const rotate = await buildSignedDeviceManifest(1130 issuerId,1131 [{1132 deviceId: "trusted-tablet",1133 deviceName: "Tablet",1134 deviceType: "android",1135 longTermPublicKeyHex: revokedKey,1136 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1137 pairedAt: 2,1138 }],1139 issuerKp.privateKey,1140 );1141 await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1142 expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1143 longTermPublicKeyHex: "7".repeat(64),1144 });1145 });11461147 it("methods throw VaultLockedError when the vault is locked", async () => {1148 const { vault, adapter } = await setupVault();1149 vault.lock();1150 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1151 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1152 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1153 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1154 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1155 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1156 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1157 });1158});