import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { once } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { createSyncServer } from './app.js'; import { b64, bytes, canonical, decrypt, encrypt, identity, open, publicIdentity, randomSecret, requestProof, seal, sign } from '../src/lib/sync/crypto.js'; import { revisionHeader } from '../src/lib/sync/model.js'; let server, base, directory, filename, offset; async function start() { server = createSyncServer({ filename, now: () => Date.now() + offset, origin: 'https://pardis.example' }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); base = `http://127.0.0.1:${server.address().port}/api/sync`; } beforeEach(async () => { directory = mkdtempSync(join(tmpdir(), 'pardis-sync-test-')); filename = join(directory, 'test.sqlite'); offset = 0; await start(); }); afterEach(async () => { await new Promise(resolve => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); }); async function request(path, device = null, body, method = body === undefined ? 'GET' : 'POST', who = device?.id, extraHeaders = {}) { const raw = body === undefined ? '' : canonical(body); const headers = { 'Content-Type': 'application/json', ...extraHeaders }; if (device) headers['X-Pardis-Proof'] = b64(bytes(canonical(await requestProof(device.privateKey, method, `/api/sync${path}`, raw, who)))); const response = await fetch(base + path, { method, headers, ...(raw ? { body: raw } : {}) }); return { status: response.status, data: await response.json(), headers, raw }; } async function vault() { const device = await identity('Phone'), recovery = await identity('Recovery'); const id = crypto.randomUUID(), epoch = crypto.randomUUID(); const ring = { v: 1, epoch, keys: { [epoch]: randomSecret() }, routingKey: randomSecret(), recovery: publicIdentity(recovery) }; const body = { id, epoch, device: publicIdentity(device), recoveryPublic: recovery.publicKey, envelope: await seal(device.wrapPublic, ring, `pardis-device-v1:${id}:${device.id}`), label: { epoch, ...await encrypt(ring.keys[epoch], device.name, `pardis-label-v1:${id}:${device.id}`) }, recoveryEnvelope: await seal(recovery.wrapPublic, ring, `pardis-recovery-v1:${id}`) }; expect((await request('/vaults', device, body)).status).toBe(200); return { device, recovery, id, epoch, ring, body }; } async function linked(v) { const b = await identity('Computer'); const invite = (await request('/invitations', v.device, {})).data; expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200); const body = { device: publicIdentity(b), epoch: v.epoch, label: { epoch: v.epoch, ...await encrypt(v.ring.keys[v.epoch], b.name, `pardis-label-v1:${v.id}:${b.id}`) }, envelope: await seal(b.wrapPublic, v.ring, `pardis-device-v1:${v.id}:${b.id}`) }; expect((await request(`/invitations/${invite.code}/approve`, v.device, body)).status).toBe(200); return { b, invite, body }; } async function revision(v, device = v.device, parents = [], object = b64(new Uint8Array(32))) { const header = revisionHeader({ vault: v.id, id: crypto.randomUUID(), object, epoch: v.epoch, device: device.id, parents }); const encrypted = await encrypt(v.ring.keys[v.epoch], { key: 'day:2026-10-10', value: 'Private chantier' }, canonical(header)); const signed = { ...header, ...encrypted }; return { ...signed, signature: await sign(device.privateKey, signed) }; } describe('synchronization API', () => { it('stores only encrypted data and scopes every read to the authenticated vault', async () => { const a = await vault(), b = await vault(); const v = await revision(a); expect((await request('/revisions', a.device, v)).status).toBe(200); expect((await request('/revisions?after=0', b.device)).data.records).toEqual([]); const result = await request('/revisions?after=0', a.device); expect(result.data.records).toHaveLength(1); const database = new DatabaseSync(filename, { readOnly: true }); const data = database.prepare('SELECT data FROM revisions').get().data; expect(data).not.toContain('Private chantier'); expect(data).not.toContain('2026-10-10'); const storedVault = database.prepare('SELECT * FROM vaults').get(); expect(JSON.stringify(storedVault)).not.toContain(a.ring.keys[a.epoch]); database.close(); }); it('requires signed, fresh requests and refuses replay or changed payloads', async () => { const a = await vault(); expect((await request('/state')).status).toBe(401); const response = await request('/state', a.device); const replay = await fetch(base + '/state', { headers: response.headers }); expect(replay.status).toBe(409); const spoofed = await identity('Spoof'); spoofed.id = a.device.id; expect((await request('/state', spoofed)).status).toBe(401); expect((await request('/state', a.device, undefined, 'GET', a.device.id, { Origin: 'https://evil.example' })).status).toBe(403); offset = 180000; expect((await request('/state', a.device)).status).toBe(401); }); it('is idempotent on vault creation, revision upload and recovery retries', async () => { const a = await vault(); expect((await request('/vaults', a.device, a.body)).status).toBe(200); const v = await revision(a); const first = await request('/revisions', a.device, v); expect((await request('/revisions', a.device, v)).data.seq).toBe(first.data.seq); expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1); const { b, body } = await linked(a); expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); expect((await request('/state', b)).data.devices).toHaveLength(2); }); it('does not disclose keys before explicit approval, and encrypts the result only to the applicant', async () => { const a = await vault(); const b = await identity('New phone'); const invite = (await request('/invitations', a.device, {})).data; expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200); const waiting = await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`); expect(waiting.data).toEqual({ waiting: true }); expect((await request('/state', b)).status).toBe(403); const heartbeat = await request(`/invitations/${invite.code}/heartbeat`, a.device, {}); expect(heartbeat.data.applicant).toEqual(publicIdentity(b)); const label = await encrypt(a.ring.keys[a.epoch], 'New', 'label'); const wrapped = await seal(b.wrapPublic, a.ring, `pardis-device-v1:${a.id}:${b.id}`); expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(b), label, envelope: wrapped })).status).toBe(200); const result = (await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`)).data; expect(await open(b.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).toEqual(a.ring); await expect(open(a.device.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).rejects.toThrow(); }); it('expires pairing when the dedicated screen stops renewing its lease', async () => { const a = await vault(); const b = await identity('B'); const invite = (await request('/invitations', a.device, {})).data; offset = 26000; expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(410); offset = 0; const fresh = (await request('/invitations', a.device, {})).data; await request(`/invitations/${fresh.code}`, a.device, {}, 'DELETE'); expect((await request('/join', null, { code: fresh.code, device: publicIdentity(b) })).status).toBe(410); }); it('binds invitation approval to its sponsor and the exact applicant keys', async () => { const a = await vault(), unrelated = await vault(); const b = await identity('B'), c = await identity('C'); const invite = (await request('/invitations', a.device, {})).data; await request('/join', null, { code: invite.code, device: publicIdentity(b) }); expect((await request('/join', null, { code: invite.code, device: publicIdentity(c) })).status).toBe(409); expect((await request(`/invitations/${invite.code}/heartbeat`, unrelated.device, {})).status).toBe(403); expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(c) })).status).toBe(409); }); it('recovers the current keyring only with the private recovery document', async () => { const a = await vault(); const bad = await identity('Wrong secret'); expect((await request('/recovery', bad, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); const result = await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`); expect(await open(a.recovery.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(a.ring); expect(a.ring.recovery).not.toHaveProperty('privateKey'); expect(a.ring.recovery).not.toHaveProperty('wrapPrivate'); }); it('atomically revokes access and rotates keys without giving the removed device the new key', async () => { const a = await vault(); const { b } = await linked(a); const epoch = crypto.randomUUID(); const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } }; const result = await request('/revoke', a.device, { target: b.id, expectedEpoch: a.epoch, epoch, envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) }, recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) }); expect(result.status).toBe(200); expect((await request('/state', b)).status).toBe(403); expect((await request('/revisions', b, await revision(a, b))).status).toBe(403); expect((await request('/revisions', a.device, await revision(a))).status).toBe(409); const state = (await request('/state', a.device)).data; expect(state.epoch).toBe(epoch); const newRing = await open(a.device.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${a.device.id}`); expect(newRing.keys[epoch]).toBe(ring.keys[epoch]); const protectedValue = await encrypt(ring.keys[epoch], 'Future data', 'data'); await expect(decrypt(a.ring.keys[a.epoch], protectedValue, 'data')).rejects.toThrow(); }); it('recovers at the device limit, revoking lost devices and rotating the epoch atomically', async () => { const a = await vault(); for (let i = 0; i < 9; i++) await linked(a); const replacement = await identity('Recovered'); const epoch = crypto.randomUUID(); const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } }; const body = { expectedEpoch: a.epoch, epoch, device: publicIdentity(replacement), label: { epoch, ...await encrypt(ring.keys[epoch], replacement.name, 'label') }, envelope: await seal(replacement.wrapPublic, ring, `pardis-device-v1:${a.id}:${replacement.id}`), recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) }; expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200); expect((await request('/state', a.device)).status).toBe(403); const state = (await request('/state', replacement)).data; expect(state.devices.filter(d => !d.revoked)).toHaveLength(1); expect(state.owner).toBe(replacement.id); expect(state.epoch).toBe(epoch); expect(await open(replacement.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${replacement.id}`)).toEqual(ring); }); it('replaces the recovery identity and rejects the old secret', async () => { const a = await vault(); const next = await identity('New recovery'); const epoch = crypto.randomUUID(); const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() }, recovery: publicIdentity(next) }; expect((await request('/recovery-reset', a.device, { target: null, expectedEpoch: a.epoch, epoch, recoveryPublic: next.publicKey, envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) }, recoveryEnvelope: await seal(next.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) })).status).toBe(200); expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); const result = await request('/recovery', next, undefined, 'GET', `recovery:${a.id}`); expect(await open(next.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(ring); }); it('checks role revisions, ancestry and revision signatures', async () => { const a = await vault(); const { b } = await linked(a); expect((await request('/role', b, { expected: 1 })).status).toBe(200); expect((await request('/role', a.device, { expected: 1 })).status).toBe(409); expect((await request('/state', b)).data.owner).toBe(b.id); const missing = await revision(a, a.device, [crypto.randomUUID()]); expect((await request('/revisions', a.device, missing)).status).toBe(409); const v = await revision(a); v.ciphertext = v.ciphertext.slice(1); expect((await request('/revisions', a.device, v)).status).toBe(401); }); it('persists encrypted records and replay protection across service restarts', async () => { const a = await vault(); const record = await revision(a); await request('/revisions', a.device, record); const state = await request('/state', a.device); await new Promise(resolve => server.close(resolve)); await start(); expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1); expect((await fetch(base + '/state', { headers: state.headers })).status).toBe(409); }); it('deletes the vault, its ciphertext and all device access', async () => { const a = await vault(); await request('/revisions', a.device, await revision(a)); expect((await request('/vault', a.device, {}, 'DELETE')).status).toBe(200); expect((await request('/state', a.device)).status).toBe(403); expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401); const database = new DatabaseSync(filename, { readOnly: true }); expect(database.prepare('SELECT count(*) n FROM revisions').get().n).toBe(0); database.close(); }); });