Added optionnal online storage and cross-client sync
All checks were successful
Tests et déploiement / validate (push) Successful in 2m44s
Tests et déploiement / deploy (push) Successful in 51s

This commit is contained in:
2026-10-10 16:01:06 +02:00
parent 277b9c887e
commit 5686ee5072
33 changed files with 2466 additions and 25 deletions

203
server/app.test.js Normal file
View File

@@ -0,0 +1,203 @@
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();
});
});