import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; const MESH_RELAY = "wss://relay.peachpasswords.com"; function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome { const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((acc, k) => { acc[k] = store[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete store[k]; }), }); return { storage: { local: area(local), sync: area(sync) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { sendMessage: vi.fn(async () => undefined) }, } as unknown as typeof chrome; } describe("SyncVaultAdapter", () => { let local: StorageShape; let sync: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; sync = {}; (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const { createSyncVaultAdapter } = await import("./sync-vault-adapter"); const adapter = createSyncVaultAdapter(); return { vault, adapter }; } async function signingPubkeyHex(deviceId: string): Promise { const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId); return pubkeyToHex(kp.publicKeyRaw); } it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => { const { adapter } = await setupVault(); const id1 = await adapter.computeVaultId(); const id2 = await adapter.computeVaultId(); expect(id1).toBe(id2); expect(id1).toMatch(/^[0-9a-f]{64}$/); }); it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => { const { vault, adapter } = await setupVault(); const deviceId = await adapter.getOurDeviceId(); const vaultIdBefore = await adapter.computeVaultId(); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const rootBefore = vault.getRootKeyHex()!; const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16))); const signBefore = pubkeyToHex( (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw, ); expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true); vault.lock(); expect(await vault.unlock("a different passphrase")).toBe(true); // vault_id derives from the stable root key, so it must not move. expect(await adapter.computeVaultId()).toBe(vaultIdBefore); const rootAfter = vault.getRootKeyHex()!; expect(rootAfter).toBe(rootBefore); const signAfter = pubkeyToHex( (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw, ); expect(signAfter).toBe(signBefore); }); it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => { const { adapter } = await setupVault(); const id1 = await adapter.getOurDeviceId(); const id2 = await adapter.getOurDeviceId(); expect(id1).toBe(id2); 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}$/); }); it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" }); await vault.deleteEntry(b.id); const m = await adapter.buildEntryManifest(); expect(Object.keys(m).sort()).toEqual([a.id]); expect(m[a.id]).toBe(a.modifiedAt); }); it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await vault.deleteEntry(a.id); const t = await adapter.buildTombstoneManifest(); expect(Object.keys(t)).toEqual([a.id]); expect(typeof t[a.id]).toBe("number"); expect(t[a.id]).toBeGreaterThan(0); }); it("buildTombstoneManifest retains ids after a full-entry purge", async () => { const { vault, adapter } = await setupVault(); const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" }); await vault.deleteEntry(entry.id); const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!; await vault.purgeEntry(entry.id); expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt }); }); it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const env = await adapter.getEncryptedEntries([a.id]); expect(Object.keys(env)).toEqual([a.id]); expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/); // Plaintext title must not be visible in the envelope. expect(env[a.id]).not.toContain("Alpha"); }); it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => { const { adapter } = await setupVault(); const env = await adapter.getEncryptedEntries(["does-not-exist"]); expect(env).toEqual({}); }); it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const e1 = await adapter.getEncryptedEntries([a.id]); const e2 = await adapter.getEncryptedEntries([a.id]); expect(e1[a.id]).not.toBe(e2[a.id]); }); it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" }); const envelopes = await adapter.getEncryptedEntries([a.id]); // Simulate a remote vault by bumping modifiedAt on the envelope's source // and then applying it back — we can't easily fork the module's storage, // so we just verify decrypt path by overwriting modifiedAt locally. // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries // will re-add it because the local copy is tombstoned with an OLDER ts (since // we set modifiedAt forward by 1s in the new envelope payload). // Instead, simpler proof: apply the envelope as-is to a fresh adapter import. vault.lock(); await vault.unlock("correct horse battery staple"); const { createSyncVaultAdapter } = await import("./sync-vault-adapter"); const adapter2 = createSyncVaultAdapter(); // applyEntries should be a no-op when local mtime >= envelope mtime await adapter2.applyEntries(envelopes); expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha"); }); it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => { const { vault, adapter } = await setupVault(); const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" }); // Manually produce a fresher remote envelope by encrypting an updated entry shape. // We can't easily synthesise this without the entry key — so we use a second // adapter instance backed by the same vault, after mutating the underlying entry. await vault.updateEntry(local.id, { title: "Updated" }); const newer = vault.getEntries().find((e) => e.id === local.id); expect(newer?.title).toBe("Updated"); const envelopes = await adapter.getEncryptedEntries([local.id]); // Now revert the local entry to an older mtime by re-creating from scratch via // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the // decrypt+merge path is that applyEntries on a fresher envelope keeps the // newer state, and on an older envelope is a no-op. We've covered the no-op // path above; for the apply path, simulate by replacing the in-memory entry // with an older copy and re-running applyEntries. const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" }; await vault.restoreEntries([older], "replace"); expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale"); await adapter.applyEntries(envelopes); expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated"); }); it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const env = await adapter.getEncryptedEntries([a.id]); // Re-key the envelope under a different id — adapter must refuse to merge it. const swapped: Record = { "wrong-id": env[a.id] }; await adapter.applyEntries(swapped); // Local entry remains unchanged; no new entry under "wrong-id" expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]); }); it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" }); await vault.updateEntry(a.id, { title: "Fresh" }); const envelopes = await adapter.getEncryptedEntries([a.id]); // Roll local back to an older copy, then apply: encrypt+decrypt both use the // null-byte-separated info, so the round-trip must succeed. const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" }; await vault.restoreEntries([stale], "replace"); await adapter.applyEntries(envelopes); expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh"); }); it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => { // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and // assert the patched adapter cannot decrypt it — it drops silently. Locks in // the conscious wire break and serves as the fixture for any future migration. const { vault, adapter } = await setupVault(); const { HKDF } = await import("@stablelib/hkdf"); const { SHA256 } = await import("@stablelib/sha256"); const masterHex = vault.getRootKeyHex()!; const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16))); const entryId = crypto.randomUUID(); const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32); const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]); const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() }; const nonce = crypto.getRandomValues(new Uint8Array(12)); const aad = new TextEncoder().encode("peach-sync-entry-v1"); const ct = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 }, key, new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer, ), ); const envelope = new Uint8Array(nonce.length + ct.length); envelope.set(nonce, 0); envelope.set(ct, nonce.length); const legacyB64 = Buffer.from(envelope).toString("base64"); await adapter.applyEntries({ [entryId]: legacyB64 }); // Decrypt fails under the new separated info → envelope dropped, entry absent. expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false); }); it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 }); expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined(); expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined(); }); it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 }); expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined(); }); // --- MV-3: sync receive bounds --- it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => { const { adapter } = await setupVault(); const { SYNC_LIMITS } = await import("../shared/sync-limits"); const tooMany: Record = {}; for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA"; await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/); }); it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => { const { vault, adapter } = await setupVault(); const { SYNC_LIMITS } = await import("../shared/sync-limits"); const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" }); await vault.updateEntry(local.id, { title: "Updated" }); const envelopes = await adapter.getEncryptedEntries([local.id]); // Make the local copy stale so the valid envelope would win on merge. const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" }; await vault.restoreEntries([older], "replace"); expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale"); const oversized = "A".repeat( Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128, ); await adapter.applyEntries({ ...envelopes, "oversized-id": oversized }); // The valid envelope applied; the oversized one was skipped (never decrypted). expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated"); expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false); }); it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => { const { adapter } = await setupVault(); const { SYNC_LIMITS } = await import("../shared/sync-limits"); const tooMany: Record = {}; for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1; await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/); }); it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => { const { adapter } = await setupVault(); const { upsertPairedDevice } = await import("./paired-devices"); await upsertPairedDevice({ deviceId: "android-1", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-1"), wsPort: 49152, relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_000, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "legacy-android", deviceName: "Legacy Pixel", deviceType: "android", longTermPublicKeyHex: "b".repeat(64), pairedAt: 1_700_000_000_100, trustSource: "direct", }); const manifest = await adapter.buildOutgoingDeviceManifest?.(); expect(manifest).toBeDefined(); expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/); expect(manifest?.body.devices).toHaveLength(1); expect(manifest?.body.devices[0]).toMatchObject({ deviceId: "android-1", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), wsPort: 49152, relayUrl: MESH_RELAY, }); }); it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, listPairedDevices, upsertPairedDevice, } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import( "./pending-vouched-devices" ); const issuerId = "extension-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "android-existing", deviceName: "Existing Pixel", deviceType: "android", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-old"), pairedAt: 1_700_000_000_010, trustSource: "direct", }); const manifest = await buildSignedDeviceManifest( issuerId, [ { deviceId: "android-new", deviceName: "New Pixel", deviceType: "android", longTermPublicKeyHex: "e".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-new"), wsPort: 51234, relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_020, }, { deviceId: "android-existing", deviceName: "Existing Pixel renamed", deviceType: "android", longTermPublicKeyHex: "f".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-new"), wsPort: 52345, relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_030, }, ], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( manifest, new Uint8Array(32).fill(0xcc), ); expect(await findPairedDeviceById("android-new")).toMatchObject({ deviceName: "New Pixel", trustSource: "vouched", relayUrl: MESH_RELAY, rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/), }); const pending = await listPendingVouchedDevices(); expect(pending).toEqual([]); // The already-paired device is still updated in place — but S5: the // third-party issuer does NOT get to rename it. Keys and ports propagate; // the locally-known name is what the user recognises and it stands. const existing = await findPairedDeviceById("android-existing"); expect(existing).toMatchObject({ deviceName: "Existing Pixel", longTermPublicKeyHex: "f".repeat(64), trustSource: "direct", wsPort: 52345, }); expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([ "android-existing", "android-new", "extension-issuer", ]); }); it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { upsertPairedDevice } = await import("./paired-devices"); const { getPendingSyncAlerts } = await import("./sync-alerts"); const issuerId = "extension-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "android-existing", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-old"), pairedAt: 2, trustSource: "direct", }); const manifest = await buildSignedDeviceManifest( issuerId, [ { deviceId: "android-existing", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "f".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-new"), wsPort: 52345, pairedAt: 3, }, ], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( manifest, new Uint8Array(32).fill(0xcc), ); // recordSyncAlerts is fire-and-forget — flush microtasks. for (let i = 0; i < 25; i++) await Promise.resolve(); const alerts = await getPendingSyncAlerts(); expect( alerts.some( (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel", ), ).toBe(true); }); it("S5: a rotation alert names the device from local state, not from the manifest", async () => { // A paired-but-hostile issuer relabels a device it is vouching for, hoping // the rotation alert reads as coming from a device the user trusts more. // The alert must show the name the user already knows, and the relabel // must not stick in the registry either. const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { getPendingSyncAlerts } = await import("./sync-alerts"); const issuerId = "hostile-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Old Laptop", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "android-existing", deviceName: "Spare Tablet", deviceType: "android", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-old"), pairedAt: 2, trustSource: "direct", }); const manifest = await buildSignedDeviceManifest( issuerId, [ { deviceId: "android-existing", deviceName: "June's Primary Phone", deviceType: "android", longTermPublicKeyHex: "f".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-new"), wsPort: 52345, pairedAt: 3, }, ], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( manifest, new Uint8Array(32).fill(0xcc), ); for (let i = 0; i < 25; i++) await Promise.resolve(); const alerts = await getPendingSyncAlerts(); const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED"); expect(rotation?.deviceName).toBe("Spare Tablet"); expect(rotation?.entryTitle).toBe("Spare Tablet"); expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false); // The key rotation itself still lands; only the name is refused. const stored = await findPairedDeviceById("android-existing"); expect(stored).toMatchObject({ deviceName: "Spare Tablet", longTermPublicKeyHex: "f".repeat(64), }); }); it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } = await import("./paired-devices"); const issuerId = "phone-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "vouched", }); await upsertPairedDevice({ deviceId: "stolen-laptop", deviceName: "Old Laptop", deviceType: "extension", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"), pairedAt: 2, trustSource: "direct", }); const revocation = await buildSignedDeviceManifest( issuerId, [], issuerKp.privateKey, { revoked: [ { deviceId: "stolen-laptop", longTermPublicKeyHex: "d".repeat(64), revokedAt: Date.now(), revokedByDeviceId: issuerId, }, ], }, ); await adapter.applyIncomingDeviceManifest?.( revocation, new Uint8Array(32).fill(0xcc), ); expect(await findPairedDeviceById("stolen-laptop")).toBeNull(); expect(await isDeviceRevoked("stolen-laptop")).toBe(true); // The stolen device is now vouched back in by a later manifest carrying a // fresh pairedAt — exactly the resurrection a timestamp-ordered design // would allow. It must not come back. const revouch = await buildSignedDeviceManifest( issuerId, [ { deviceId: "stolen-laptop", deviceName: "Old Laptop", deviceType: "extension", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"), pairedAt: Date.now() + 60_000, }, ], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( revouch, new Uint8Array(32).fill(0xcc), ); expect(await findPairedDeviceById("stolen-laptop")).toBeNull(); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); expect(await listPendingVouchedDevices()).toHaveLength(0); }); it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice, } = await import("./paired-devices"); const { getOrCreateDeviceId } = await import("./device-identity"); const ourDeviceId = await getOrCreateDeviceId(); const issuerId = "phone-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); // A compromised peer tries to sever the mesh by revoking this device. const selfRevoke = await buildSignedDeviceManifest( issuerId, [], issuerKp.privateKey, { revoked: [ { deviceId: ourDeviceId, longTermPublicKeyHex: "e".repeat(64), revokedAt: Date.now(), revokedByDeviceId: issuerId, }, ], }, ); await adapter.applyIncomingDeviceManifest?.( selfRevoke, new Uint8Array(32).fill(0xcc), ); expect(await isDeviceRevoked(ourDeviceId)).toBe(false); // Our own revocations ride out in the manifest we build, and the revoked // device is absent from the vouched list. await upsertPairedDevice({ deviceId: "retired-tablet", deviceName: "Retired Tablet", deviceType: "android", longTermPublicKeyHex: "f".repeat(64), signingPublicKeyHex: await signingPubkeyHex("retired-tablet"), pairedAt: 3, trustSource: "direct", }); await revokeDeviceById("retired-tablet", ourDeviceId); const outgoing = await adapter.buildOutgoingDeviceManifest?.(); expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]); expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet"); }); // PB-26-116: rows written before `trustSource` existed carry no provenance. // The manifest-apply path used to default them to "direct", which is the // opposite of the fail-closed rule PB-26-006 established for the mesh routing // gate — a legacy row became a dial target, manifest issuer, and revocation- // repair participant on the strength of a signed manifest alone. it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role"); const issuerId = "android-legacy-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Issuer Pixel", deviceType: "android", longTermPublicKeyHex: "1".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "direct", }); // A row predating the field: present in the registry, no recorded // provenance. Seeded through storage so no writer stamps a default. const legacyId = "legacy-peer"; const registry = local.peach_paired_devices_v3 as Array>; registry.push({ deviceId: legacyId, deviceName: "Legacy Peer", deviceType: "android", longTermPublicKeyHex: "3".repeat(64), signingPublicKeyHex: await signingPubkeyHex(legacyId), rendezvousSecretHex: "9".repeat(64), relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_000, }); const manifest = await buildSignedDeviceManifest( issuerId, [{ deviceId: legacyId, deviceName: "Legacy Peer", deviceType: "android", longTermPublicKeyHex: "3".repeat(64), signingPublicKeyHex: await signingPubkeyHex(legacyId), relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_000, }], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( manifest, new Uint8Array(32).fill(0x11), ); const row = await findPairedDeviceById(legacyId); expect(row).not.toBeNull(); // Not silently promoted: unknown provenance stays unknown. expect(row?.trustSource).toBeUndefined(); expect(row?.trustSource).not.toBe("direct"); // And therefore not a routing authority. expect(isRouteCapableMeshPeer(row!)).toBe(false); }); it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => { // The browser can never listen, so it joins the mesh THROUGH a native peer; // that peer is the one holding the peer list. A phone-issued manifest must // therefore be honoured. MV-7 still applies: the introduced device lands in // the pending queue, not the registry. const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); const mobileIssuerId = "android-issuer"; const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId); await upsertPairedDevice({ deviceId: mobileIssuerId, deviceName: "Issuer Pixel", deviceType: "android", longTermPublicKeyHex: "1".repeat(64), signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "direct", }); const mobileManifest = await buildSignedDeviceManifest( mobileIssuerId, [{ deviceId: "mesh-peer", deviceName: "Other Pixel", deviceType: "android", longTermPublicKeyHex: "2".repeat(64), signingPublicKeyHex: await signingPubkeyHex("mesh-peer"), relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_010, }], mobileIssuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( mobileManifest, new Uint8Array(32).fill(0x11), ); expect(await findPairedDeviceById("mesh-peer")).toMatchObject({ trustSource: "vouched", relayUrl: MESH_RELAY, rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/), }); expect(await listPendingVouchedDevices()).toEqual([]); }); it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); // Once admitted, a peer propagates the pool without relying on the device // that introduced it. The Noise-authenticated key and stored signing key // still bind the manifest to this known mesh member. const vouchedIssuerId = "android-vouched-issuer"; const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId); await upsertPairedDevice({ deviceId: vouchedIssuerId, deviceName: "Second-hand Pixel", deviceType: "android", longTermPublicKeyHex: "1".repeat(64), signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "vouched", }); const onward = await buildSignedDeviceManifest( vouchedIssuerId, [{ deviceId: "onward-mesh-peer", deviceName: "Other Pixel", deviceType: "android", longTermPublicKeyHex: "2".repeat(64), signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"), relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_010, }], vouchedKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( onward, new Uint8Array(32).fill(0x11), ); expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({ trustSource: "vouched", relayUrl: MESH_RELAY, rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/), }); expect(await listPendingVouchedDevices()).toEqual([]); const extensionIssuerId = "extension-bad-sig"; const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId); await upsertPairedDevice({ deviceId: extensionIssuerId, deviceName: "Chrome", deviceType: "extension", longTermPublicKeyHex: "3".repeat(64), signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw), pairedAt: 1_700_000_000_020, trustSource: "direct", }); const good = await buildSignedDeviceManifest( extensionIssuerId, [{ deviceId: "forged-add", deviceName: "Forged Pixel", deviceType: "android", longTermPublicKeyHex: "4".repeat(64), signingPublicKeyHex: await signingPubkeyHex("forged-add"), pairedAt: 1_700_000_000_030, }], extensionKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( good, new Uint8Array(32).fill(0x44), ); expect(await findPairedDeviceById("forged-add")).toBeNull(); const forged = { ...good, signatureHex: good.signatureHex.slice(0, 2) + ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) + good.signatureHex.slice(4), }; await adapter.applyIncomingDeviceManifest?.( forged, new Uint8Array(32).fill(0x33), ); expect(await findPairedDeviceById("forged-add")).toBeNull(); }); it("falls back to derived signing key when stored issuer key is stale", async () => { const { vault, adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); const issuerId = "stale-key-android"; const rootHex = vault.getRootKeyHex()!; const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16))); const issuerKp = await deriveSyncSigningKeypair(root, issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "c".repeat(64), // Stale: an older build persisted this at first-pair under a now-bad // seed. The fallback must recover from this without trusting the row. signingPublicKeyHex: "ff".repeat(32), pairedAt: 1_700_000_000_000, trustSource: "direct", }); const manifest = await buildSignedDeviceManifest( issuerId, [{ deviceId: "onward-peer", deviceName: "Forwarded iPad", deviceType: "extension", longTermPublicKeyHex: "e".repeat(64), signingPublicKeyHex: pubkeyToHex( (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw, ), relayUrl: MESH_RELAY, pairedAt: 1_700_000_000_010, }], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.( manifest, new Uint8Array(32).fill(0xcc), ); expect(await findPairedDeviceById("onward-peer")).toMatchObject({ trustSource: "vouched", relayUrl: MESH_RELAY, rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/), }); expect(await listPendingVouchedDevices()).toEqual([]); }); it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); // Both peers are DIRECTLY paired, so neither the trustSource gate nor an // unknown-key lookup can be what rejects this — only the binding between // the Noise-authenticated static key and the manifest's claimed issuer. const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha"); const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta"); await upsertPairedDevice({ deviceId: "peer-alpha", deviceName: "Alpha", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "peer-beta", deviceName: "Beta", deviceType: "android", longTermPublicKeyHex: "b".repeat(64), signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw), pairedAt: 2, trustSource: "direct", }); const fromBeta = await buildSignedDeviceManifest( "peer-beta", [{ deviceId: "smuggled", deviceName: "Smuggled", deviceType: "android", longTermPublicKeyHex: "5".repeat(64), signingPublicKeyHex: await signingPubkeyHex("smuggled"), relayUrl: MESH_RELAY, pairedAt: 3, }], betaKp.privateKey, ); // Delivered over a session Noise-authenticated as ALPHA. await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa)); expect(await listPendingVouchedDevices()).toEqual([]); // Same manifest, same devices, delivered over BETA's session: it must land. // Without this control the assertion above could pass for any reason. await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb)); const { findPairedDeviceById } = await import("./paired-devices"); expect(await findPairedDeviceById("smuggled")).toMatchObject({ trustSource: "vouched", relayUrl: MESH_RELAY, }); expect(await listPendingVouchedDevices()).toEqual([]); }); it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import("./pending-vouched-devices"); const issuerId = "phone-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId); const stolenKey = "d".repeat(64); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "stolen-laptop", deviceName: "Old Laptop", deviceType: "extension", longTermPublicKeyHex: stolenKey, signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"), pairedAt: 2, trustSource: "direct", }); const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, { revoked: [{ deviceId: "stolen-laptop", longTermPublicKeyHex: stolenKey, revokedAt: Date.now(), revokedByDeviceId: issuerId, }], }); await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc)); expect(await findPairedDeviceById("stolen-laptop")).toBeNull(); // deviceId is the issuer's to choose, so re-vouching the same key under a // new one is free. Terminality has to follow the key, not the label. const renamed = await buildSignedDeviceManifest( issuerId, [{ deviceId: "stolen-laptop-mk2", deviceName: "New Laptop", deviceType: "extension", longTermPublicKeyHex: stolenKey, signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"), relayUrl: MESH_RELAY, pairedAt: Date.now(), }], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc)); expect(await listPendingVouchedDevices()).toEqual([]); expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull(); // Control: the same fresh id carrying a key that was never revoked queues // normally, so the rejection above is the key check and not the id. const clean = await buildSignedDeviceManifest( issuerId, [{ deviceId: "stolen-laptop-mk2", deviceName: "New Laptop", deviceType: "extension", longTermPublicKeyHex: "9".repeat(64), signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"), relayUrl: MESH_RELAY, pairedAt: Date.now(), }], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc)); expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({ trustSource: "vouched", relayUrl: MESH_RELAY, }); expect(await listPendingVouchedDevices()).toEqual([]); }); it("S1: a revoked key cannot be written onto a device we still trust", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const issuerId = "phone-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId); const revokedKey = "d".repeat(64); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "trusted-tablet", deviceName: "Tablet", deviceType: "android", longTermPublicKeyHex: "7".repeat(64), signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"), pairedAt: 2, trustSource: "direct", }); const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, { revoked: [{ deviceId: "stolen-laptop", longTermPublicKeyHex: revokedKey, revokedAt: Date.now(), revokedByDeviceId: issuerId, }], }); await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc)); // The manifest rotates a device we already trust onto the revoked key — // the same re-admission dressed as a key rotation. const rotate = await buildSignedDeviceManifest( issuerId, [{ deviceId: "trusted-tablet", deviceName: "Tablet", deviceType: "android", longTermPublicKeyHex: revokedKey, signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"), pairedAt: 2, }], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc)); expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({ longTermPublicKeyHex: "7".repeat(64), }); }); it("methods throw VaultLockedError when the vault is locked", async () => { const { vault, adapter } = await setupVault(); vault.lock(); await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" }); }); });