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 } = await import("../shared/legacy-identity-derivation.test-support");53 const { pubkeyToHex } = await import("../shared/sync-identity");54 const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);55 return pubkeyToHex(kp.publicKeyRaw);56 }5758 it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {59 const { adapter } = await setupVault();60 const id1 = await adapter.computeVaultId();61 const id2 = await adapter.computeVaultId();62 expect(id1).toBe(id2);63 expect(id1).toMatch(/^[0-9a-f]{64}$/);64 });6566 it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {67 const { vault, adapter } = await setupVault();68 const deviceId = await adapter.getOurDeviceId();69 const vaultIdBefore = await adapter.computeVaultId();7071 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");72 const { pubkeyToHex } = await import("../shared/sync-identity");73 const rootBefore = vault.getRootKeyHex()!;74 const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));75 const signBefore = pubkeyToHex(76 (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,77 );7879 expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);80 vault.lock();81 expect(await vault.unlock("a different passphrase")).toBe(true);8283 84 expect(await adapter.computeVaultId()).toBe(vaultIdBefore);85 const rootAfter = vault.getRootKeyHex()!;86 expect(rootAfter).toBe(rootBefore);87 const signAfter = pubkeyToHex(88 (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,89 );90 expect(signAfter).toBe(signBefore);91 });9293 it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {94 const { adapter } = await setupVault();95 const id1 = await adapter.getOurDeviceId();96 const id2 = await adapter.getOurDeviceId();97 expect(id1).toBe(id2);98 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}$/);99 });100101 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {102 const { vault, adapter } = await setupVault();103 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });104 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });105 await vault.deleteEntry(b.id);106 const m = await adapter.buildEntryManifest();107 expect(Object.keys(m).sort()).toEqual([a.id]);108 expect(m[a.id]).toBe(a.modifiedAt);109 });110111 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {112 const { vault, adapter } = await setupVault();113 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });114 await vault.deleteEntry(a.id);115 const t = await adapter.buildTombstoneManifest();116 expect(Object.keys(t)).toEqual([a.id]);117 expect(typeof t[a.id]).toBe("number");118 expect(t[a.id]).toBeGreaterThan(0);119 });120121 it("buildTombstoneManifest retains ids after a full-entry purge", async () => {122 const { vault, adapter } = await setupVault();123 const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });124 await vault.deleteEntry(entry.id);125 const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;126127 await vault.purgeEntry(entry.id);128129 expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });130 });131132 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {133 const { vault, adapter } = await setupVault();134 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });135 const env = await adapter.getEncryptedEntries([a.id]);136 expect(Object.keys(env)).toEqual([a.id]);137 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);138 139 expect(env[a.id]).not.toContain("Alpha");140 });141142 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {143 const { adapter } = await setupVault();144 const env = await adapter.getEncryptedEntries(["does-not-exist"]);145 expect(env).toEqual({});146 });147148 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {149 const { vault, adapter } = await setupVault();150 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });151 const e1 = await adapter.getEncryptedEntries([a.id]);152 const e2 = await adapter.getEncryptedEntries([a.id]);153 expect(e1[a.id]).not.toBe(e2[a.id]);154 });155156 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {157 const { vault, adapter } = await setupVault();158 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });159 const envelopes = await adapter.getEncryptedEntries([a.id]);160 161 162 163 164 165 166 167 vault.lock();168 await vault.unlock("correct horse battery staple");169 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");170 const adapter2 = createSyncVaultAdapter();171 172 await adapter2.applyEntries(envelopes);173 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");174 });175176 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {177 const { vault, adapter } = await setupVault();178 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });179 180 181 182 await vault.updateEntry(local.id, { title: "Updated" });183 const newer = vault.getEntries().find((e) => e.id === local.id);184 expect(newer?.title).toBe("Updated");185 const envelopes = await adapter.getEncryptedEntries([local.id]);186 187 188 189 190 191 192 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };193 await vault.restoreEntries([older], "replace");194 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");195 await adapter.applyEntries(envelopes);196 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");197 });198199 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {200 const { vault, adapter } = await setupVault();201 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });202 const env = await adapter.getEncryptedEntries([a.id]);203 204 const swapped: Record<string, string> = { "wrong-id": env[a.id] };205 await adapter.applyEntries(swapped);206 207 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);208 });209210 it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {211 const { vault, adapter } = await setupVault();212 const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });213 await vault.updateEntry(a.id, { title: "Fresh" });214 const envelopes = await adapter.getEncryptedEntries([a.id]);215 216 217 const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };218 await vault.restoreEntries([stale], "replace");219 await adapter.applyEntries(envelopes);220 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");221 });222223 it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {224 225 226 227 const { vault, adapter } = await setupVault();228 const { HKDF } = await import("@stablelib/hkdf");229 const { SHA256 } = await import("@stablelib/sha256");230231 const masterHex = vault.getRootKeyHex()!;232 const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));233 const entryId = crypto.randomUUID();234 const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); 235 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);236 const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);237238 const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };239 const nonce = crypto.getRandomValues(new Uint8Array(12));240 const aad = new TextEncoder().encode("peach-sync-entry-v1");241 const ct = new Uint8Array(242 await crypto.subtle.encrypt(243 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },244 key,245 new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,246 ),247 );248 const envelope = new Uint8Array(nonce.length + ct.length);249 envelope.set(nonce, 0);250 envelope.set(ct, nonce.length);251 const legacyB64 = Buffer.from(envelope).toString("base64");252253 await adapter.applyEntries({ [entryId]: legacyB64 });254 255 expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);256 });257258 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {259 const { vault, adapter } = await setupVault();260 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });261 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });262 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();263 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();264 });265266 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {267 const { vault, adapter } = await setupVault();268 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });269 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });270 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();271 });272273 274275 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {276 const { adapter } = await setupVault();277 const { SYNC_LIMITS } = await import("../shared/sync-limits");278 const tooMany: Record<string, string> = {};279 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";280 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);281 });282283 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {284 const { vault, adapter } = await setupVault();285 const { SYNC_LIMITS } = await import("../shared/sync-limits");286 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });287 await vault.updateEntry(local.id, { title: "Updated" });288 const envelopes = await adapter.getEncryptedEntries([local.id]);289 290 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };291 await vault.restoreEntries([older], "replace");292 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");293294 const oversized = "A".repeat(295 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,296 );297 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });298299 300 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");301 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);302 });303304 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {305 const { adapter } = await setupVault();306 const { SYNC_LIMITS } = await import("../shared/sync-limits");307 const tooMany: Record<string, number> = {};308 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;309 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);310 });311312 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {313 const { adapter } = await setupVault();314 const { upsertPairedDevice } = await import("./paired-devices");315 await upsertPairedDevice({316 deviceId: "android-1",317 deviceName: "Pixel",318 deviceType: "android",319 longTermPublicKeyHex: "a".repeat(64),320 signingPublicKeyHex: await signingPubkeyHex("android-1"),321 wsPort: 49152,322 relayUrl: MESH_RELAY,323 pairedAt: 1_700_000_000_000,324 trustSource: "direct",325 });326 await upsertPairedDevice({327 deviceId: "legacy-android",328 deviceName: "Legacy Pixel",329 deviceType: "android",330 longTermPublicKeyHex: "b".repeat(64),331 pairedAt: 1_700_000_000_100,332 trustSource: "direct",333 });334335 const manifest = await adapter.buildOutgoingDeviceManifest?.();336 expect(manifest).toBeDefined();337 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);338 expect(manifest?.body.devices).toHaveLength(1);339 expect(manifest?.body.devices[0]).toMatchObject({340 deviceId: "android-1",341 deviceName: "Pixel",342 deviceType: "android",343 longTermPublicKeyHex: "a".repeat(64),344 wsPort: 49152,345 relayUrl: MESH_RELAY,346 });347 });348349 it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {350 const { vault, adapter } = await setupVault();351 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");352 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");353 const { pubkeyToHex } = await import("../shared/sync-identity");354 const {355 findPairedDeviceById,356 listPairedDevices,357 upsertPairedDevice,358 } = await import("./paired-devices");359 const { listPendingVouchedDevices } = await import(360 "./pending-vouched-devices"361 );362363 const issuerId = "extension-issuer";364 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);365 await upsertPairedDevice({366 deviceId: issuerId,367 deviceName: "Firefox",368 deviceType: "extension",369 longTermPublicKeyHex: "c".repeat(64),370 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),371 pairedAt: 1_700_000_000_000,372 trustSource: "direct",373 });374 await upsertPairedDevice({375 deviceId: "android-existing",376 deviceName: "Existing Pixel",377 deviceType: "android",378 longTermPublicKeyHex: "d".repeat(64),379 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),380 pairedAt: 1_700_000_000_010,381 trustSource: "direct",382 });383384 const manifest = await buildSignedDeviceManifest(385 issuerId,386 [387 {388 deviceId: "android-new",389 deviceName: "New Pixel",390 deviceType: "android",391 longTermPublicKeyHex: "e".repeat(64),392 signingPublicKeyHex: await signingPubkeyHex("android-new"),393 wsPort: 51234,394 relayUrl: MESH_RELAY,395 pairedAt: 1_700_000_000_020,396 },397 {398 deviceId: "android-existing",399 deviceName: "Existing Pixel renamed",400 deviceType: "android",401 longTermPublicKeyHex: "d".repeat(64),402 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),403 wsPort: 52345,404 relayUrl: MESH_RELAY,405 pairedAt: 1_700_000_000_030,406 },407 ],408 issuerKp.privateKey,409 );410411 await adapter.applyIncomingDeviceManifest?.(412 manifest,413 new Uint8Array(32).fill(0xcc),414 );415416 expect(await findPairedDeviceById("android-new")).toMatchObject({417 deviceName: "New Pixel",418 trustSource: "vouched",419 relayUrl: MESH_RELAY,420 });421 422 423 expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();424 const { getPairSecret } = await import("./pair-secrets");425 await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(426 /^[0-9a-f]{64}$/,427 );428 const pending = await listPendingVouchedDevices();429 expect(pending).toEqual([]);430431 432 433 const existing = await findPairedDeviceById("android-existing");434 expect(existing).toMatchObject({435 deviceName: "Existing Pixel",436 longTermPublicKeyHex: "d".repeat(64),437 trustSource: "direct",438 wsPort: 52345,439 });440 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([441 "android-existing",442 "android-new",443 "extension-issuer",444 ]);445 });446447 it("rejects manifest key replacement for an existing device", async () => {448 const { adapter } = await setupVault();449 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");450 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");451 const { pubkeyToHex } = await import("../shared/sync-identity");452 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");453 const issuerId = "extension-issuer";454 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);455 await upsertPairedDevice({456 deviceId: issuerId,457 deviceName: "Firefox",458 deviceType: "extension",459 longTermPublicKeyHex: "c".repeat(64),460 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),461 pairedAt: 1,462 trustSource: "direct",463 });464 await upsertPairedDevice({465 deviceId: "android-existing",466 deviceName: "Pixel",467 deviceType: "android",468 longTermPublicKeyHex: "d".repeat(64),469 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),470 pairedAt: 2,471 trustSource: "direct",472 });473 const manifest = await buildSignedDeviceManifest(474 issuerId,475 [{476 deviceId: "android-existing",477 deviceName: "Pixel",478 deviceType: "android",479 longTermPublicKeyHex: "f".repeat(64),480 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),481 wsPort: 52345,482 pairedAt: 3,483 }],484 issuerKp.privateKey,485 );486 await expect(487 adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),488 ).rejects.toThrow("device_identity_immutable");489 await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({490 longTermPublicKeyHex: "d".repeat(64),491 });492 });493494 it("rejects manifest replacement even when the device name changes", async () => {495 const { adapter } = await setupVault();496 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");497 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");498 const { pubkeyToHex } = await import("../shared/sync-identity");499 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");500 const issuerId = "hostile-issuer";501 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);502 await upsertPairedDevice({503 deviceId: issuerId,504 deviceName: "Old Laptop",505 deviceType: "extension",506 longTermPublicKeyHex: "c".repeat(64),507 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),508 pairedAt: 1,509 trustSource: "direct",510 });511 await upsertPairedDevice({512 deviceId: "android-existing",513 deviceName: "Spare Tablet",514 deviceType: "android",515 longTermPublicKeyHex: "d".repeat(64),516 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),517 pairedAt: 2,518 trustSource: "direct",519 });520 const manifest = await buildSignedDeviceManifest(521 issuerId,522 [{523 deviceId: "android-existing",524 deviceName: "June's Primary Phone",525 deviceType: "android",526 longTermPublicKeyHex: "f".repeat(64),527 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),528 wsPort: 52345,529 pairedAt: 3,530 }],531 issuerKp.privateKey,532 );533 await expect(534 adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),535 ).rejects.toThrow("device_identity_immutable");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 issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);572 const signedRevocation = await signDeviceRevocation(573 {574 deviceId: "stolen-laptop",575 longTermPublicKeyHex: "d".repeat(64),576 revokedAt: Date.now(),577 revokedByDeviceId: issuerId,578 },579 issuerKp.privateKey,580 issuerSigningPublicKeyHex,581 );582 const revocation = await buildSignedDeviceManifest(583 issuerId,584 [],585 issuerKp.privateKey,586 { revoked: [signedRevocation] },587 );588 await adapter.applyIncomingDeviceManifest?.(589 revocation,590 new Uint8Array(32).fill(0xcc),591 );592593 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();594 expect(await isDeviceRevoked("stolen-laptop")).toBe(true);595596 597 598 599 const revouch = await buildSignedDeviceManifest(600 issuerId,601 [602 {603 deviceId: "stolen-laptop",604 deviceName: "Old Laptop",605 deviceType: "extension",606 longTermPublicKeyHex: "d".repeat(64),607 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),608 pairedAt: Date.now() + 60_000,609 },610 ],611 issuerKp.privateKey,612 );613 await adapter.applyIncomingDeviceManifest?.(614 revouch,615 new Uint8Array(32).fill(0xcc),616 );617618 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();619 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");620 expect(await listPendingVouchedDevices()).toHaveLength(0);621 });622623 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {624 const { adapter } = await setupVault();625 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");626 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");627 const { pubkeyToHex } = await import("../shared/sync-identity");628 const {629 isDeviceRevoked,630 revokeDeviceById,631 upsertPairedDevice,632 } = await import("./paired-devices");633 const { getOrCreateDeviceId } = await import("./device-identity");634635 const ourDeviceId = await getOrCreateDeviceId();636 const issuerId = "phone-issuer";637 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);638 await upsertPairedDevice({639 deviceId: issuerId,640 deviceName: "Pixel",641 deviceType: "android",642 longTermPublicKeyHex: "c".repeat(64),643 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),644 pairedAt: 1,645 trustSource: "direct",646 });647648 649 const selfRevoke = await buildSignedDeviceManifest(650 issuerId,651 [],652 issuerKp.privateKey,653 {654 revoked: [655 {656 deviceId: ourDeviceId,657 longTermPublicKeyHex: "e".repeat(64),658 revokedAt: Date.now(),659 revokedByDeviceId: issuerId,660 },661 ],662 },663 );664 await adapter.applyIncomingDeviceManifest?.(665 selfRevoke,666 new Uint8Array(32).fill(0xcc),667 );668 expect(await isDeviceRevoked(ourDeviceId)).toBe(false);669670 671 672 await upsertPairedDevice({673 deviceId: "retired-tablet",674 deviceName: "Retired Tablet",675 deviceType: "android",676 longTermPublicKeyHex: "f".repeat(64),677 signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),678 pairedAt: 3,679 trustSource: "direct",680 });681 await revokeDeviceById("retired-tablet", ourDeviceId);682683 const outgoing = await adapter.buildOutgoingDeviceManifest?.();684 expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);685 expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");686 });687688 689 690 691 692 693 it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {694 const { adapter } = await setupVault();695 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");696 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");697 const { pubkeyToHex } = await import("../shared/sync-identity");698 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");699 const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");700701 const issuerId = "android-legacy-issuer";702 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);703 await upsertPairedDevice({704 deviceId: issuerId,705 deviceName: "Issuer Pixel",706 deviceType: "android",707 longTermPublicKeyHex: "1".repeat(64),708 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),709 pairedAt: 1_700_000_000_000,710 trustSource: "direct",711 });712713 714 715 const legacyId = "legacy-peer";716 const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;717 registry.push({718 deviceId: legacyId,719 deviceName: "Legacy Peer",720 deviceType: "android",721 longTermPublicKeyHex: "3".repeat(64),722 signingPublicKeyHex: await signingPubkeyHex(legacyId),723 rendezvousSecretHex: "9".repeat(64),724 relayUrl: MESH_RELAY,725 pairedAt: 1_700_000_000_000,726 });727728 const manifest = await buildSignedDeviceManifest(729 issuerId,730 [{731 deviceId: legacyId,732 deviceName: "Legacy Peer",733 deviceType: "android",734 longTermPublicKeyHex: "3".repeat(64),735 signingPublicKeyHex: await signingPubkeyHex(legacyId),736 relayUrl: MESH_RELAY,737 pairedAt: 1_700_000_000_000,738 }],739 issuerKp.privateKey,740 );741 await adapter.applyIncomingDeviceManifest?.(742 manifest,743 new Uint8Array(32).fill(0x11),744 );745746 const row = await findPairedDeviceById(legacyId);747 expect(row).not.toBeNull();748 749 expect(row?.trustSource).toBeUndefined();750 expect(row?.trustSource).not.toBe("direct");751 752 expect(isRouteCapableMeshPeer(row!)).toBe(false);753 });754755 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {756 757 758 759 760 const { adapter } = await setupVault();761 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");762 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");763 const { pubkeyToHex } = await import("../shared/sync-identity");764 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");765 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");766 const vault = await import("./vault");767768 const mobileIssuerId = "android-issuer";769 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);770 await upsertPairedDevice({771 deviceId: mobileIssuerId,772 deviceName: "Issuer Pixel",773 deviceType: "android",774 longTermPublicKeyHex: "1".repeat(64),775 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),776 pairedAt: 1_700_000_000_000,777 trustSource: "direct",778 });779 const mobileManifest = await buildSignedDeviceManifest(780 mobileIssuerId,781 [{782 deviceId: "mesh-peer",783 deviceName: "Other Pixel",784 deviceType: "android",785 longTermPublicKeyHex: "2".repeat(64),786 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),787 relayUrl: MESH_RELAY,788 pairedAt: 1_700_000_000_010,789 }],790 mobileIssuerKp.privateKey,791 );792 await adapter.applyIncomingDeviceManifest?.(793 mobileManifest,794 new Uint8Array(32).fill(0x11),795 );796797 expect(await findPairedDeviceById("mesh-peer")).toMatchObject({798 trustSource: "vouched",799 relayUrl: MESH_RELAY,800 });801 expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();802 const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");803 await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(804 /^[0-9a-f]{64}$/,805 );806 expect(await listPendingVouchedDevices()).toEqual([]);807 });808809 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {810 const { vault, adapter } = await setupVault();811 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");812 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");813 const { pubkeyToHex } = await import("../shared/sync-identity");814 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");815 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");816817 818 819 820 const vouchedIssuerId = "android-vouched-issuer";821 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);822 await upsertPairedDevice({823 deviceId: vouchedIssuerId,824 deviceName: "Second-hand Pixel",825 deviceType: "android",826 longTermPublicKeyHex: "1".repeat(64),827 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),828 pairedAt: 1_700_000_000_000,829 trustSource: "vouched",830 });831 const onward = await buildSignedDeviceManifest(832 vouchedIssuerId,833 [{834 deviceId: "onward-mesh-peer",835 deviceName: "Other Pixel",836 deviceType: "android",837 longTermPublicKeyHex: "2".repeat(64),838 signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),839 relayUrl: MESH_RELAY,840 pairedAt: 1_700_000_000_010,841 }],842 vouchedKp.privateKey,843 );844 await adapter.applyIncomingDeviceManifest?.(845 onward,846 new Uint8Array(32).fill(0x11),847 );848 expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({849 trustSource: "vouched",850 relayUrl: MESH_RELAY,851 });852 expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();853 const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");854 await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(855 /^[0-9a-f]{64}$/,856 );857 expect(await listPendingVouchedDevices()).toEqual([]);858859 const extensionIssuerId = "extension-bad-sig";860 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);861 await upsertPairedDevice({862 deviceId: extensionIssuerId,863 deviceName: "Chrome",864 deviceType: "extension",865 longTermPublicKeyHex: "3".repeat(64),866 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),867 pairedAt: 1_700_000_000_020,868 trustSource: "direct",869 });870 const good = await buildSignedDeviceManifest(871 extensionIssuerId,872 [{873 deviceId: "forged-add",874 deviceName: "Forged Pixel",875 deviceType: "android",876 longTermPublicKeyHex: "4".repeat(64),877 signingPublicKeyHex: await signingPubkeyHex("forged-add"),878 pairedAt: 1_700_000_000_030,879 }],880 extensionKp.privateKey,881 );882 await adapter.applyIncomingDeviceManifest?.(883 good,884 new Uint8Array(32).fill(0x44),885 );886 expect(await findPairedDeviceById("forged-add")).toBeNull();887888 const forged = {889 ...good,890 signatureHex:891 good.signatureHex.slice(0, 2) +892 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +893 good.signatureHex.slice(4),894 };895 await adapter.applyIncomingDeviceManifest?.(896 forged,897 new Uint8Array(32).fill(0x33),898 );899 expect(await findPairedDeviceById("forged-add")).toBeNull();900 });901902 it("rejects a manifest when the stored issuer signing key is stale", async () => {903 const { vault, adapter } = await setupVault();904 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");905 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");906 const { pubkeyToHex } = await import("../shared/sync-identity");907 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");908909 const issuerId = "stale-key-android";910 const rootHex = vault.getRootKeyHex()!;911 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));912 const issuerKp = await deriveSyncSigningKeypair(root, issuerId);913914 await upsertPairedDevice({915 deviceId: issuerId,916 deviceName: "Pixel",917 deviceType: "android",918 longTermPublicKeyHex: "c".repeat(64),919 signingPublicKeyHex: "ff".repeat(32),920 pairedAt: 1_700_000_000_000,921 trustSource: "direct",922 });923924 const manifest = await buildSignedDeviceManifest(925 issuerId,926 [{927 deviceId: "onward-peer",928 deviceName: "Forwarded iPad",929 deviceType: "extension",930 longTermPublicKeyHex: "e".repeat(64),931 signingPublicKeyHex: pubkeyToHex(932 (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,933 ),934 relayUrl: MESH_RELAY,935 pairedAt: 1_700_000_000_010,936 }],937 issuerKp.privateKey,938 );939940 await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));941942 expect(await findPairedDeviceById("onward-peer")).toBeNull();943 });944945 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {946 const { adapter } = await setupVault();947 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");948 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");949 const { pubkeyToHex } = await import("../shared/sync-identity");950 const { upsertPairedDevice } = await import("./paired-devices");951 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");952953 954 955 956 const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");957 const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");958 await upsertPairedDevice({959 deviceId: "peer-alpha",960 deviceName: "Alpha",961 deviceType: "android",962 longTermPublicKeyHex: "a".repeat(64),963 signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),964 pairedAt: 1,965 trustSource: "direct",966 });967 await upsertPairedDevice({968 deviceId: "peer-beta",969 deviceName: "Beta",970 deviceType: "android",971 longTermPublicKeyHex: "b".repeat(64),972 signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),973 pairedAt: 2,974 trustSource: "direct",975 });976977 const fromBeta = await buildSignedDeviceManifest(978 "peer-beta",979 [{980 deviceId: "smuggled",981 deviceName: "Smuggled",982 deviceType: "android",983 longTermPublicKeyHex: "5".repeat(64),984 signingPublicKeyHex: await signingPubkeyHex("smuggled"),985 relayUrl: MESH_RELAY,986 pairedAt: 3,987 }],988 betaKp.privateKey,989 );990991 992 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));993 expect(await listPendingVouchedDevices()).toEqual([]);994995 996 997 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));998 const { findPairedDeviceById } = await import("./paired-devices");999 expect(await findPairedDeviceById("smuggled")).toMatchObject({1000 trustSource: "vouched",1001 relayUrl: MESH_RELAY,1002 });1003 expect(await listPendingVouchedDevices()).toEqual([]);1004 });1005 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1006 const { adapter } = await setupVault();1007 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1008 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1009 const { pubkeyToHex } = await import("../shared/sync-identity");1010 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1011 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10121013 const issuerId = "phone-issuer";1014 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1015 const stolenKey = "d".repeat(64);1016 await upsertPairedDevice({1017 deviceId: issuerId,1018 deviceName: "Pixel",1019 deviceType: "android",1020 longTermPublicKeyHex: "c".repeat(64),1021 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1022 pairedAt: 1,1023 trustSource: "direct",1024 });1025 await upsertPairedDevice({1026 deviceId: "stolen-laptop",1027 deviceName: "Old Laptop",1028 deviceType: "extension",1029 longTermPublicKeyHex: stolenKey,1030 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1031 pairedAt: 2,1032 trustSource: "direct",1033 });1034 const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1035 const signedRevocation = await signDeviceRevocation({1036 deviceId: "stolen-laptop",1037 longTermPublicKeyHex: stolenKey,1038 revokedAt: Date.now(),1039 revokedByDeviceId: issuerId,1040 }, issuerKp.privateKey, issuerSigningPublicKeyHex);1041 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1042 revoked: [signedRevocation],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, signDeviceRevocation } = await import("../shared/device-manifest");1092 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1093 const { pubkeyToHex } = await import("../shared/sync-identity");1094 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10951096 const issuerId = "phone-issuer";1097 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1098 const revokedKey = "d".repeat(64);1099 await upsertPairedDevice({1100 deviceId: issuerId,1101 deviceName: "Pixel",1102 deviceType: "android",1103 longTermPublicKeyHex: "c".repeat(64),1104 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1105 pairedAt: 1,1106 trustSource: "direct",1107 });1108 await upsertPairedDevice({1109 deviceId: "trusted-tablet",1110 deviceName: "Tablet",1111 deviceType: "android",1112 longTermPublicKeyHex: "7".repeat(64),1113 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1114 pairedAt: 2,1115 trustSource: "direct",1116 });11171118 const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1119 const signedRevocation = await signDeviceRevocation({1120 deviceId: "stolen-laptop",1121 longTermPublicKeyHex: revokedKey,1122 revokedAt: Date.now(),1123 revokedByDeviceId: issuerId,1124 }, issuerKp.privateKey, issuerSigningPublicKeyHex);1125 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1126 revoked: [signedRevocation],1127 });1128 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11291130 1131 1132 const rotate = await buildSignedDeviceManifest(1133 issuerId,1134 [{1135 deviceId: "trusted-tablet",1136 deviceName: "Tablet",1137 deviceType: "android",1138 longTermPublicKeyHex: revokedKey,1139 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1140 pairedAt: 2,1141 }],1142 issuerKp.privateKey,1143 );1144 await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1145 expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1146 longTermPublicKeyHex: "7".repeat(64),1147 });1148 });11491150 it("methods throw VaultLockedError when the vault is locked", async () => {1151 const { vault, adapter } = await setupVault();1152 vault.lock();1153 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1154 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1155 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1156 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1157 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1158 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1159 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1160 });1161});