Added optionnal online storage and cross-client sync
This commit is contained in:
203
server/app.test.js
Normal file
203
server/app.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user