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("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {120 const { vault, adapter } = await setupVault();121 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });122 const env = await adapter.getEncryptedEntries([a.id]);123 expect(Object.keys(env)).toEqual([a.id]);124 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);125 126 expect(env[a.id]).not.toContain("Alpha");127 });128129 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {130 const { adapter } = await setupVault();131 const env = await adapter.getEncryptedEntries(["does-not-exist"]);132 expect(env).toEqual({});133 });134135 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {136 const { vault, adapter } = await setupVault();137 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });138 const e1 = await adapter.getEncryptedEntries([a.id]);139 const e2 = await adapter.getEncryptedEntries([a.id]);140 expect(e1[a.id]).not.toBe(e2[a.id]);141 });142143 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {144 const { vault, adapter } = await setupVault();145 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });146 const envelopes = await adapter.getEncryptedEntries([a.id]);147 148 149 150 151 152 153 154 vault.lock();155 await vault.unlock("correct horse battery staple");156 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");157 const adapter2 = createSyncVaultAdapter();158 159 await adapter2.applyEntries(envelopes);160 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");161 });162163 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {164 const { vault, adapter } = await setupVault();165 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });166 167 168 169 await vault.updateEntry(local.id, { title: "Updated" });170 const newer = vault.getEntries().find((e) => e.id === local.id);171 expect(newer?.title).toBe("Updated");172 const envelopes = await adapter.getEncryptedEntries([local.id]);173 174 175 176 177 178 179 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };180 await vault.restoreEntries([older], "replace");181 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");182 await adapter.applyEntries(envelopes);183 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");184 });185186 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {187 const { vault, adapter } = await setupVault();188 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });189 const env = await adapter.getEncryptedEntries([a.id]);190 191 const swapped: Record<string, string> = { "wrong-id": env[a.id] };192 await adapter.applyEntries(swapped);193 194 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);195 });196197 it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {198 const { vault, adapter } = await setupVault();199 const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });200 await vault.updateEntry(a.id, { title: "Fresh" });201 const envelopes = await adapter.getEncryptedEntries([a.id]);202 203 204 const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };205 await vault.restoreEntries([stale], "replace");206 await adapter.applyEntries(envelopes);207 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");208 });209210 it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {211 212 213 214 const { vault, adapter } = await setupVault();215 const { HKDF } = await import("@stablelib/hkdf");216 const { SHA256 } = await import("@stablelib/sha256");217218 const masterHex = vault.getRootKeyHex()!;219 const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));220 const entryId = crypto.randomUUID();221 const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); 222 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);223 const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);224225 const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };226 const nonce = crypto.getRandomValues(new Uint8Array(12));227 const aad = new TextEncoder().encode("peach-sync-entry-v1");228 const ct = new Uint8Array(229 await crypto.subtle.encrypt(230 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },231 key,232 new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,233 ),234 );235 const envelope = new Uint8Array(nonce.length + ct.length);236 envelope.set(nonce, 0);237 envelope.set(ct, nonce.length);238 const legacyB64 = Buffer.from(envelope).toString("base64");239240 await adapter.applyEntries({ [entryId]: legacyB64 });241 242 expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);243 });244245 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {246 const { vault, adapter } = await setupVault();247 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });248 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });249 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();250 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();251 });252253 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {254 const { vault, adapter } = await setupVault();255 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });256 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });257 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();258 });259260 261262 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {263 const { adapter } = await setupVault();264 const { SYNC_LIMITS } = await import("../shared/sync-limits");265 const tooMany: Record<string, string> = {};266 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";267 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);268 });269270 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {271 const { vault, adapter } = await setupVault();272 const { SYNC_LIMITS } = await import("../shared/sync-limits");273 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });274 await vault.updateEntry(local.id, { title: "Updated" });275 const envelopes = await adapter.getEncryptedEntries([local.id]);276 277 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };278 await vault.restoreEntries([older], "replace");279 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");280281 const oversized = "A".repeat(282 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,283 );284 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });285286 287 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");288 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);289 });290291 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {292 const { adapter } = await setupVault();293 const { SYNC_LIMITS } = await import("../shared/sync-limits");294 const tooMany: Record<string, number> = {};295 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;296 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);297 });298299 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {300 const { adapter } = await setupVault();301 const { upsertPairedDevice } = await import("./paired-devices");302 await upsertPairedDevice({303 deviceId: "android-1",304 deviceName: "Pixel",305 deviceType: "android",306 longTermPublicKeyHex: "a".repeat(64),307 signingPublicKeyHex: await signingPubkeyHex("android-1"),308 wsPort: 49152,309 relayUrl: MESH_RELAY,310 pairedAt: 1_700_000_000_000,311 trustSource: "direct",312 });313 await upsertPairedDevice({314 deviceId: "legacy-android",315 deviceName: "Legacy Pixel",316 deviceType: "android",317 longTermPublicKeyHex: "b".repeat(64),318 pairedAt: 1_700_000_000_100,319 trustSource: "direct",320 });321322 const manifest = await adapter.buildOutgoingDeviceManifest?.();323 expect(manifest).toBeDefined();324 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);325 expect(manifest?.body.devices).toHaveLength(1);326 expect(manifest?.body.devices[0]).toMatchObject({327 deviceId: "android-1",328 deviceName: "Pixel",329 deviceType: "android",330 longTermPublicKeyHex: "a".repeat(64),331 wsPort: 49152,332 relayUrl: MESH_RELAY,333 });334 });335336 it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {337 const { adapter } = await setupVault();338 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");339 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");340 const {341 findPairedDeviceById,342 listPairedDevices,343 upsertPairedDevice,344 } = await import("./paired-devices");345 const { listPendingVouchedDevices } = await import(346 "./pending-vouched-devices"347 );348349 const issuerId = "extension-issuer";350 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);351 await upsertPairedDevice({352 deviceId: issuerId,353 deviceName: "Firefox",354 deviceType: "extension",355 longTermPublicKeyHex: "c".repeat(64),356 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),357 pairedAt: 1_700_000_000_000,358 trustSource: "direct",359 });360 await upsertPairedDevice({361 deviceId: "android-existing",362 deviceName: "Existing Pixel",363 deviceType: "android",364 longTermPublicKeyHex: "d".repeat(64),365 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),366 pairedAt: 1_700_000_000_010,367 trustSource: "direct",368 });369370 const manifest = await buildSignedDeviceManifest(371 issuerId,372 [373 {374 deviceId: "android-new",375 deviceName: "New Pixel",376 deviceType: "android",377 longTermPublicKeyHex: "e".repeat(64),378 signingPublicKeyHex: await signingPubkeyHex("android-new"),379 wsPort: 51234,380 relayUrl: MESH_RELAY,381 pairedAt: 1_700_000_000_020,382 },383 {384 deviceId: "android-existing",385 deviceName: "Existing Pixel renamed",386 deviceType: "android",387 longTermPublicKeyHex: "f".repeat(64),388 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),389 wsPort: 52345,390 relayUrl: MESH_RELAY,391 pairedAt: 1_700_000_000_030,392 },393 ],394 issuerKp.privateKey,395 );396397 await adapter.applyIncomingDeviceManifest?.(398 manifest,399 new Uint8Array(32).fill(0xcc),400 );401402 expect(await findPairedDeviceById("android-new")).toMatchObject({403 deviceName: "New Pixel",404 trustSource: "vouched",405 relayUrl: MESH_RELAY,406 rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),407 });408 const pending = await listPendingVouchedDevices();409 expect(pending).toEqual([]);410411 412 413 414 const existing = await findPairedDeviceById("android-existing");415 expect(existing).toMatchObject({416 deviceName: "Existing Pixel",417 longTermPublicKeyHex: "f".repeat(64),418 trustSource: "direct",419 wsPort: 52345,420 });421 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([422 "android-existing",423 "android-new",424 "extension-issuer",425 ]);426 });427428 it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {429 const { adapter } = await setupVault();430 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");431 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");432 const { upsertPairedDevice } = await import("./paired-devices");433 const { getPendingSyncAlerts } = await import("./sync-alerts");434435 const issuerId = "extension-issuer";436 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);437 await upsertPairedDevice({438 deviceId: issuerId,439 deviceName: "Firefox",440 deviceType: "extension",441 longTermPublicKeyHex: "c".repeat(64),442 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),443 pairedAt: 1,444 trustSource: "direct",445 });446 await upsertPairedDevice({447 deviceId: "android-existing",448 deviceName: "Pixel",449 deviceType: "android",450 longTermPublicKeyHex: "d".repeat(64),451 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),452 pairedAt: 2,453 trustSource: "direct",454 });455456 const manifest = await buildSignedDeviceManifest(457 issuerId,458 [459 {460 deviceId: "android-existing",461 deviceName: "Pixel",462 deviceType: "android",463 longTermPublicKeyHex: "f".repeat(64),464 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),465 wsPort: 52345,466 pairedAt: 3,467 },468 ],469 issuerKp.privateKey,470 );471472 await adapter.applyIncomingDeviceManifest?.(473 manifest,474 new Uint8Array(32).fill(0xcc),475 );476 477 for (let i = 0; i < 25; i++) await Promise.resolve();478479 const alerts = await getPendingSyncAlerts();480 expect(481 alerts.some(482 (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",483 ),484 ).toBe(true);485 });486487 it("S5: a rotation alert names the device from local state, not from the manifest", async () => {488 489 490 491 492 const { adapter } = await setupVault();493 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");494 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");495 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");496 const { getPendingSyncAlerts } = await import("./sync-alerts");497498 const issuerId = "hostile-issuer";499 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);500 await upsertPairedDevice({501 deviceId: issuerId,502 deviceName: "Old Laptop",503 deviceType: "extension",504 longTermPublicKeyHex: "c".repeat(64),505 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),506 pairedAt: 1,507 trustSource: "direct",508 });509 await upsertPairedDevice({510 deviceId: "android-existing",511 deviceName: "Spare Tablet",512 deviceType: "android",513 longTermPublicKeyHex: "d".repeat(64),514 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),515 pairedAt: 2,516 trustSource: "direct",517 });518519 const manifest = await buildSignedDeviceManifest(520 issuerId,521 [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 ],532 issuerKp.privateKey,533 );534535 await adapter.applyIncomingDeviceManifest?.(536 manifest,537 new Uint8Array(32).fill(0xcc),538 );539 for (let i = 0; i < 25; i++) await Promise.resolve();540541 const alerts = await getPendingSyncAlerts();542 const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");543 expect(rotation?.deviceName).toBe("Spare Tablet");544 expect(rotation?.entryTitle).toBe("Spare Tablet");545 expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);546547 548 const stored = await findPairedDeviceById("android-existing");549 expect(stored).toMatchObject({550 deviceName: "Spare Tablet",551 longTermPublicKeyHex: "f".repeat(64),552 });553 });554555 it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {556 const { adapter } = await setupVault();557 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");558 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");559 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =560 await import("./paired-devices");561562 const issuerId = "phone-issuer";563 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);564 await upsertPairedDevice({565 deviceId: issuerId,566 deviceName: "Pixel",567 deviceType: "android",568 longTermPublicKeyHex: "c".repeat(64),569 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),570 pairedAt: 1,571 trustSource: "vouched",572 });573 await upsertPairedDevice({574 deviceId: "stolen-laptop",575 deviceName: "Old Laptop",576 deviceType: "extension",577 longTermPublicKeyHex: "d".repeat(64),578 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),579 pairedAt: 2,580 trustSource: "direct",581 });582583 const revocation = await buildSignedDeviceManifest(584 issuerId,585 [],586 issuerKp.privateKey,587 {588 revoked: [589 {590 deviceId: "stolen-laptop",591 longTermPublicKeyHex: "d".repeat(64),592 revokedAt: Date.now(),593 revokedByDeviceId: issuerId,594 },595 ],596 },597 );598 await adapter.applyIncomingDeviceManifest?.(599 revocation,600 new Uint8Array(32).fill(0xcc),601 );602603 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();604 expect(await isDeviceRevoked("stolen-laptop")).toBe(true);605606 607 608 609 const revouch = await buildSignedDeviceManifest(610 issuerId,611 [612 {613 deviceId: "stolen-laptop",614 deviceName: "Old Laptop",615 deviceType: "extension",616 longTermPublicKeyHex: "d".repeat(64),617 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),618 pairedAt: Date.now() + 60_000,619 },620 ],621 issuerKp.privateKey,622 );623 await adapter.applyIncomingDeviceManifest?.(624 revouch,625 new Uint8Array(32).fill(0xcc),626 );627628 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();629 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");630 expect(await listPendingVouchedDevices()).toHaveLength(0);631 });632633 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {634 const { adapter } = await setupVault();635 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");636 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");637 const {638 isDeviceRevoked,639 revokeDeviceById,640 upsertPairedDevice,641 } = await import("./paired-devices");642 const { getOrCreateDeviceId } = await import("./device-identity");643644 const ourDeviceId = await getOrCreateDeviceId();645 const issuerId = "phone-issuer";646 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);647 await upsertPairedDevice({648 deviceId: issuerId,649 deviceName: "Pixel",650 deviceType: "android",651 longTermPublicKeyHex: "c".repeat(64),652 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),653 pairedAt: 1,654 trustSource: "direct",655 });656657 658 const selfRevoke = await buildSignedDeviceManifest(659 issuerId,660 [],661 issuerKp.privateKey,662 {663 revoked: [664 {665 deviceId: ourDeviceId,666 longTermPublicKeyHex: "e".repeat(64),667 revokedAt: Date.now(),668 revokedByDeviceId: issuerId,669 },670 ],671 },672 );673 await adapter.applyIncomingDeviceManifest?.(674 selfRevoke,675 new Uint8Array(32).fill(0xcc),676 );677 expect(await isDeviceRevoked(ourDeviceId)).toBe(false);678679 680 681 await upsertPairedDevice({682 deviceId: "retired-tablet",683 deviceName: "Retired Tablet",684 deviceType: "android",685 longTermPublicKeyHex: "f".repeat(64),686 signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),687 pairedAt: 3,688 trustSource: "direct",689 });690 await revokeDeviceById("retired-tablet", ourDeviceId);691692 const outgoing = await adapter.buildOutgoingDeviceManifest?.();693 expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);694 expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");695 });696697 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {698 699 700 701 702 const { adapter } = await setupVault();703 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");704 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");705 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");706 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");707708 const mobileIssuerId = "android-issuer";709 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);710 await upsertPairedDevice({711 deviceId: mobileIssuerId,712 deviceName: "Issuer Pixel",713 deviceType: "android",714 longTermPublicKeyHex: "1".repeat(64),715 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),716 pairedAt: 1_700_000_000_000,717 trustSource: "direct",718 });719 const mobileManifest = await buildSignedDeviceManifest(720 mobileIssuerId,721 [{722 deviceId: "mesh-peer",723 deviceName: "Other Pixel",724 deviceType: "android",725 longTermPublicKeyHex: "2".repeat(64),726 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),727 relayUrl: MESH_RELAY,728 pairedAt: 1_700_000_000_010,729 }],730 mobileIssuerKp.privateKey,731 );732 await adapter.applyIncomingDeviceManifest?.(733 mobileManifest,734 new Uint8Array(32).fill(0x11),735 );736737 expect(await findPairedDeviceById("mesh-peer")).toMatchObject({738 trustSource: "vouched",739 relayUrl: MESH_RELAY,740 rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),741 });742 expect(await listPendingVouchedDevices()).toEqual([]);743 });744745 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {746 const { adapter } = await setupVault();747 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");748 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");749 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");750 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");751752 753 754 755 const vouchedIssuerId = "android-vouched-issuer";756 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);757 await upsertPairedDevice({758 deviceId: vouchedIssuerId,759 deviceName: "Second-hand Pixel",760 deviceType: "android",761 longTermPublicKeyHex: "1".repeat(64),762 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),763 pairedAt: 1_700_000_000_000,764 trustSource: "vouched",765 });766 const onward = await buildSignedDeviceManifest(767 vouchedIssuerId,768 [{769 deviceId: "onward-mesh-peer",770 deviceName: "Other Pixel",771 deviceType: "android",772 longTermPublicKeyHex: "2".repeat(64),773 signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),774 relayUrl: MESH_RELAY,775 pairedAt: 1_700_000_000_010,776 }],777 vouchedKp.privateKey,778 );779 await adapter.applyIncomingDeviceManifest?.(780 onward,781 new Uint8Array(32).fill(0x11),782 );783 expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({784 trustSource: "vouched",785 relayUrl: MESH_RELAY,786 rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),787 });788 expect(await listPendingVouchedDevices()).toEqual([]);789790 const extensionIssuerId = "extension-bad-sig";791 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);792 await upsertPairedDevice({793 deviceId: extensionIssuerId,794 deviceName: "Chrome",795 deviceType: "extension",796 longTermPublicKeyHex: "3".repeat(64),797 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),798 pairedAt: 1_700_000_000_020,799 trustSource: "direct",800 });801 const good = await buildSignedDeviceManifest(802 extensionIssuerId,803 [{804 deviceId: "forged-add",805 deviceName: "Forged Pixel",806 deviceType: "android",807 longTermPublicKeyHex: "4".repeat(64),808 signingPublicKeyHex: await signingPubkeyHex("forged-add"),809 pairedAt: 1_700_000_000_030,810 }],811 extensionKp.privateKey,812 );813 await adapter.applyIncomingDeviceManifest?.(814 good,815 new Uint8Array(32).fill(0x44),816 );817 expect(await findPairedDeviceById("forged-add")).toBeNull();818819 const forged = {820 ...good,821 signatureHex:822 good.signatureHex.slice(0, 2) +823 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +824 good.signatureHex.slice(4),825 };826 await adapter.applyIncomingDeviceManifest?.(827 forged,828 new Uint8Array(32).fill(0x33),829 );830 expect(await findPairedDeviceById("forged-add")).toBeNull();831 });832833 it("falls back to derived signing key when stored issuer key is stale", async () => {834 const { vault, adapter } = await setupVault();835 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");836 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");837 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");838 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");839840 const issuerId = "stale-key-android";841 const rootHex = vault.getRootKeyHex()!;842 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));843 const issuerKp = await deriveSyncSigningKeypair(root, issuerId);844845 await upsertPairedDevice({846 deviceId: issuerId,847 deviceName: "Pixel",848 deviceType: "android",849 longTermPublicKeyHex: "c".repeat(64),850 851 852 signingPublicKeyHex: "ff".repeat(32),853 pairedAt: 1_700_000_000_000,854 trustSource: "direct",855 });856857 const manifest = await buildSignedDeviceManifest(858 issuerId,859 [{860 deviceId: "onward-peer",861 deviceName: "Forwarded iPad",862 deviceType: "extension",863 longTermPublicKeyHex: "e".repeat(64),864 signingPublicKeyHex: pubkeyToHex(865 (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,866 ),867 relayUrl: MESH_RELAY,868 pairedAt: 1_700_000_000_010,869 }],870 issuerKp.privateKey,871 );872873 await adapter.applyIncomingDeviceManifest?.(874 manifest,875 new Uint8Array(32).fill(0xcc),876 );877878 expect(await findPairedDeviceById("onward-peer")).toMatchObject({879 trustSource: "vouched",880 relayUrl: MESH_RELAY,881 rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),882 });883 expect(await listPendingVouchedDevices()).toEqual([]);884 });885886 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {887 const { adapter } = await setupVault();888 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");889 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");890 const { upsertPairedDevice } = await import("./paired-devices");891 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");892893 894 895 896 const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");897 const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");898 await upsertPairedDevice({899 deviceId: "peer-alpha",900 deviceName: "Alpha",901 deviceType: "android",902 longTermPublicKeyHex: "a".repeat(64),903 signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),904 pairedAt: 1,905 trustSource: "direct",906 });907 await upsertPairedDevice({908 deviceId: "peer-beta",909 deviceName: "Beta",910 deviceType: "android",911 longTermPublicKeyHex: "b".repeat(64),912 signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),913 pairedAt: 2,914 trustSource: "direct",915 });916917 const fromBeta = await buildSignedDeviceManifest(918 "peer-beta",919 [{920 deviceId: "smuggled",921 deviceName: "Smuggled",922 deviceType: "android",923 longTermPublicKeyHex: "5".repeat(64),924 signingPublicKeyHex: await signingPubkeyHex("smuggled"),925 relayUrl: MESH_RELAY,926 pairedAt: 3,927 }],928 betaKp.privateKey,929 );930931 932 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));933 expect(await listPendingVouchedDevices()).toEqual([]);934935 936 937 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));938 const { findPairedDeviceById } = await import("./paired-devices");939 expect(await findPairedDeviceById("smuggled")).toMatchObject({940 trustSource: "vouched",941 relayUrl: MESH_RELAY,942 });943 expect(await listPendingVouchedDevices()).toEqual([]);944 });945946 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {947 const { adapter } = await setupVault();948 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");949 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");950 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");951 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");952953 const issuerId = "phone-issuer";954 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);955 const stolenKey = "d".repeat(64);956 await upsertPairedDevice({957 deviceId: issuerId,958 deviceName: "Pixel",959 deviceType: "android",960 longTermPublicKeyHex: "c".repeat(64),961 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),962 pairedAt: 1,963 trustSource: "direct",964 });965 await upsertPairedDevice({966 deviceId: "stolen-laptop",967 deviceName: "Old Laptop",968 deviceType: "extension",969 longTermPublicKeyHex: stolenKey,970 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),971 pairedAt: 2,972 trustSource: "direct",973 });974975 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {976 revoked: [{977 deviceId: "stolen-laptop",978 longTermPublicKeyHex: stolenKey,979 revokedAt: Date.now(),980 revokedByDeviceId: issuerId,981 }],982 });983 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));984 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();985986 987 988 const renamed = await buildSignedDeviceManifest(989 issuerId,990 [{991 deviceId: "stolen-laptop-mk2",992 deviceName: "New Laptop",993 deviceType: "extension",994 longTermPublicKeyHex: stolenKey,995 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),996 relayUrl: MESH_RELAY,997 pairedAt: Date.now(),998 }],999 issuerKp.privateKey,1000 );1001 await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1002 expect(await listPendingVouchedDevices()).toEqual([]);1003 expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10041005 1006 1007 const clean = await buildSignedDeviceManifest(1008 issuerId,1009 [{1010 deviceId: "stolen-laptop-mk2",1011 deviceName: "New Laptop",1012 deviceType: "extension",1013 longTermPublicKeyHex: "9".repeat(64),1014 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1015 relayUrl: MESH_RELAY,1016 pairedAt: Date.now(),1017 }],1018 issuerKp.privateKey,1019 );1020 await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1021 expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1022 trustSource: "vouched",1023 relayUrl: MESH_RELAY,1024 });1025 expect(await listPendingVouchedDevices()).toEqual([]);1026 });10271028 it("S1: a revoked key cannot be written onto a device we still trust", async () => {1029 const { adapter } = await setupVault();1030 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1031 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1032 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10331034 const issuerId = "phone-issuer";1035 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1036 const revokedKey = "d".repeat(64);1037 await upsertPairedDevice({1038 deviceId: issuerId,1039 deviceName: "Pixel",1040 deviceType: "android",1041 longTermPublicKeyHex: "c".repeat(64),1042 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1043 pairedAt: 1,1044 trustSource: "direct",1045 });1046 await upsertPairedDevice({1047 deviceId: "trusted-tablet",1048 deviceName: "Tablet",1049 deviceType: "android",1050 longTermPublicKeyHex: "7".repeat(64),1051 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1052 pairedAt: 2,1053 trustSource: "direct",1054 });10551056 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1057 revoked: [{1058 deviceId: "stolen-laptop",1059 longTermPublicKeyHex: revokedKey,1060 revokedAt: Date.now(),1061 revokedByDeviceId: issuerId,1062 }],1063 });1064 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));10651066 1067 1068 const rotate = await buildSignedDeviceManifest(1069 issuerId,1070 [{1071 deviceId: "trusted-tablet",1072 deviceName: "Tablet",1073 deviceType: "android",1074 longTermPublicKeyHex: revokedKey,1075 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1076 pairedAt: 2,1077 }],1078 issuerKp.privateKey,1079 );1080 await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1081 expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1082 longTermPublicKeyHex: "7".repeat(64),1083 });1084 });10851086 it("methods throw VaultLockedError when the vault is locked", async () => {1087 const { vault, adapter } = await setupVault();1088 vault.lock();1089 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1090 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1091 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1092 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1093 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1094 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1095 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1096 });1097});