Added optionnal online storage and cross-client sync
This commit is contained in:
256
server/app.js
Normal file
256
server/app.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { canonical, digest, verify } from '../src/lib/sync/crypto.js';
|
||||
import { revisionHeader } from '../src/lib/sync/model.js';
|
||||
|
||||
const PREFIX = '/api/sync';
|
||||
const parse = value => JSON.parse(value);
|
||||
const json = value => JSON.stringify(value);
|
||||
const fail = (status, message, code = undefined) => { throw Object.assign(new Error(message), { status, code }); };
|
||||
const uuid = value => typeof value === 'string' && /^[a-f\d-]{36}$/.test(value);
|
||||
const envelope = value => value && typeof value.ciphertext === 'string' && value.ciphertext.length < 200_000 && (typeof value.iv === 'string' || typeof value.enc === 'string');
|
||||
const publicDevice = d => d && uuid(d.id) && d.publicKey?.kty === 'EC' && d.publicKey.crv === 'P-256' && !d.publicKey.d && typeof d.wrapPublic === 'string' && d.wrapPublic.length < 200;
|
||||
|
||||
export function createSyncServer({ filename = ':memory:', origin = '', maxBytes = 50 * 1024 * 1024, maxVaults = 10000, trustProxy = false, now = Date.now } = {}) {
|
||||
const db = new DatabaseSync(filename);
|
||||
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS vaults (id TEXT PRIMARY KEY, recovery_pub TEXT NOT NULL, recovery_envelope TEXT NOT NULL, epoch TEXT NOT NULL, owner TEXT NOT NULL, role_version INTEGER NOT NULL DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, pub TEXT NOT NULL, wrap TEXT NOT NULL, label TEXT NOT NULL, envelope TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, seen INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS revisions (seq INTEGER PRIMARY KEY AUTOINCREMENT, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, id TEXT NOT NULL, object TEXT NOT NULL, data TEXT NOT NULL, size INTEGER NOT NULL, UNIQUE(vault,id));
|
||||
CREATE INDEX IF NOT EXISTS revisions_vault ON revisions(vault,seq);
|
||||
CREATE TABLE IF NOT EXISTS invitations (code TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, sponsor TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, expires INTEGER NOT NULL, lease INTEGER NOT NULL, applicant TEXT, approved INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS nonces (id TEXT PRIMARY KEY, expires INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS nonces_expiration ON nonces(expires);
|
||||
`);
|
||||
const get = (sql, ...args) => db.prepare(sql).get(...args);
|
||||
const all = (sql, ...args) => db.prepare(sql).all(...args);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
const atomic = action => { db.exec('BEGIN IMMEDIATE'); try { const result = action(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } };
|
||||
const limits = new Map();
|
||||
function limit(key, count) {
|
||||
const time = now();
|
||||
if (limits.size > 10000) for (const [k, v] of limits) if (v.end < time) limits.delete(k);
|
||||
const state = limits.get(key) ?? { count: 0, end: time + 60_000 };
|
||||
if (time > state.end) { state.count = 0; state.end = time + 60_000; }
|
||||
state.count++; limits.set(key, state);
|
||||
if (state.count > count || limits.size > 20000) fail(429, 'Trop de tentatives. Réessayez dans une minute.');
|
||||
}
|
||||
function liveInvite(code, approved = false) {
|
||||
const invite = get('SELECT * FROM invitations WHERE code=?', code);
|
||||
if (!invite || invite.expires < now() || (!invite.approved && invite.lease < now()) || (!approved && invite.approved)) fail(410, 'Invitation expirée ou fermée.');
|
||||
return invite;
|
||||
}
|
||||
function addDevice(vault, device, label, wrapped) {
|
||||
if (!publicDevice(device) || !envelope(label) || !envelope(wrapped)) fail(400, 'Appareil invalide.');
|
||||
const existing = get('SELECT * FROM devices WHERE id=?', device.id);
|
||||
if (existing) {
|
||||
if (existing.vault === vault && !existing.revoked && canonical(parse(existing.pub)) === canonical(device.publicKey) && existing.wrap === device.wrapPublic) return;
|
||||
fail(409, 'Identifiant déjà utilisé.');
|
||||
}
|
||||
if (get('SELECT count(*) n FROM devices WHERE vault=? AND revoked=0', vault).n >= 10) fail(409, 'Dix appareils maximum par coffre.');
|
||||
run('INSERT INTO devices(id,vault,pub,wrap,label,envelope,seen) VALUES(?,?,?,?,?,?,?)', device.id, vault, json(device.publicKey), device.wrapPublic, json(label), json(wrapped), now());
|
||||
}
|
||||
async function authenticate(req, path, raw, body) {
|
||||
let auth;
|
||||
try { auth = parse(Buffer.from(String(req.headers['x-pardis-proof'] ?? ''), 'base64url').toString()); } catch { fail(401, 'Authentification requise.'); }
|
||||
const { proof, signature } = auth ?? {};
|
||||
if (!proof || typeof proof.who !== 'string' || !uuid(proof.nonce) || !Number.isSafeInteger(proof.time) || Math.abs(now() - proof.time) > 120_000 || proof.method !== req.method || proof.path !== path || proof.hash !== await digest(raw)) fail(401, 'Requête expirée ou invalide. Vérifiez l’horloge de cet appareil.');
|
||||
let pub, device, vault;
|
||||
if (path === `${PREFIX}/vaults` && req.method === 'POST') {
|
||||
if (!publicDevice(body.device) || proof.who !== body.device.id) fail(400, 'Identité invalide.');
|
||||
pub = body.device.publicKey;
|
||||
} else if (proof.who.startsWith('recovery:')) {
|
||||
vault = get('SELECT * FROM vaults WHERE id=?', proof.who.slice(9));
|
||||
if (!['/recovery', '/recover'].some(p => path === PREFIX + p) || !vault) fail(401, 'Secours invalide.');
|
||||
pub = parse(vault.recovery_pub);
|
||||
} else if (proof.who.startsWith('pair:')) {
|
||||
const code = proof.who.slice(5);
|
||||
const invite = liveInvite(code, true);
|
||||
if (path !== `${PREFIX}/invitations/${code}/result` || !invite.applicant) fail(401, 'Association invalide.');
|
||||
pub = parse(invite.applicant).publicKey;
|
||||
} else {
|
||||
device = get('SELECT * FROM devices WHERE id=? AND revoked=0', proof.who);
|
||||
if (!device) fail(403, 'Cet appareil n’est plus autorisé.', 'DEVICE_REVOKED');
|
||||
vault = get('SELECT * FROM vaults WHERE id=?', device.vault);
|
||||
pub = parse(device.pub);
|
||||
}
|
||||
if (!await verify(pub, proof, signature)) fail(401, 'Signature invalide.');
|
||||
run('DELETE FROM nonces WHERE expires<?', now());
|
||||
if (get('SELECT id FROM nonces WHERE id=?', proof.nonce)) fail(409, 'Requête déjà utilisée.');
|
||||
run('INSERT INTO nonces VALUES(?,?)', proof.nonce, now() + 240_000);
|
||||
if (device) {
|
||||
device = get('SELECT * FROM devices WHERE id=? AND revoked=0', device.id);
|
||||
if (!device) fail(403, 'Cet appareil n’est plus autorisé.', 'DEVICE_REVOKED');
|
||||
vault = get('SELECT * FROM vaults WHERE id=?', device.vault);
|
||||
run('UPDATE devices SET seen=? WHERE id=?', now(), device.id);
|
||||
} else if (vault) {
|
||||
const fresh = get('SELECT * FROM vaults WHERE id=?', vault.id);
|
||||
if (!fresh || fresh.recovery_pub !== vault.recovery_pub) fail(401, 'Secret de secours remplacé.');
|
||||
vault = fresh;
|
||||
}
|
||||
return { device, vault };
|
||||
}
|
||||
async function route(req, url, raw, body, ip) {
|
||||
const path = url.pathname;
|
||||
if (path === `${PREFIX}/health` && req.method === 'GET') return { ok: true };
|
||||
if (path === `${PREFIX}/join` && req.method === 'POST') {
|
||||
limit(`join:${ip}`, 12);
|
||||
if (!publicDevice(body.device)) fail(400, 'Identité invalide.');
|
||||
const invite = liveInvite(String(body.code));
|
||||
const sponsor = get('SELECT * FROM devices WHERE id=? AND revoked=0', invite.sponsor);
|
||||
if (!sponsor) fail(410, 'Invitation fermée.');
|
||||
if (invite.applicant && canonical(parse(invite.applicant)) !== canonical(body.device)) fail(409, 'Une demande est déjà en cours.');
|
||||
run('UPDATE invitations SET applicant=? WHERE code=?', json(body.device), invite.code);
|
||||
return { code: invite.code, expires: invite.expires, sponsor: { id: sponsor.id, publicKey: parse(sponsor.pub), wrapPublic: sponsor.wrap }, vault: invite.vault };
|
||||
}
|
||||
const { device, vault } = await authenticate(req, path + url.search, raw, body);
|
||||
if (path === `${PREFIX}/vaults` && req.method === 'POST') {
|
||||
limit(`create:${ip}`, 5);
|
||||
if (!uuid(body.id) || !uuid(body.epoch) || !body.recoveryPublic || body.recoveryPublic.d || body.recoveryPublic.kty !== 'EC' || !envelope(body.recoveryEnvelope)) fail(400, 'Coffre invalide.');
|
||||
const existing = get('SELECT * FROM vaults WHERE id=?', body.id);
|
||||
if (existing) {
|
||||
const member = get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.device.id, body.id);
|
||||
if (member && canonical(parse(member.pub)) === canonical(body.device.publicKey)) return { id: body.id };
|
||||
fail(409, 'Coffre déjà existant.');
|
||||
}
|
||||
if (get('SELECT count(*) n FROM vaults').n >= maxVaults) fail(507, 'Capacité du service atteinte.');
|
||||
atomic(() => {
|
||||
run('INSERT INTO vaults(id,recovery_pub,recovery_envelope,epoch,owner) VALUES(?,?,?,?,?)', body.id, json(body.recoveryPublic), json(body.recoveryEnvelope), body.epoch, body.device.id);
|
||||
addDevice(body.id, body.device, body.label, body.envelope);
|
||||
});
|
||||
return { id: body.id };
|
||||
}
|
||||
if (path === `${PREFIX}/recovery` && req.method === 'GET') return { id: vault.id, epoch: vault.epoch, envelope: parse(vault.recovery_envelope) };
|
||||
if (path === `${PREFIX}/recover` && req.method === 'POST') {
|
||||
const existing = publicDevice(body.device) && get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.device.id, vault.id);
|
||||
if (existing && canonical(parse(existing.pub)) === canonical(body.device.publicKey)) return { ok: true };
|
||||
if (body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Les clés ont changé. Recommencez la récupération.');
|
||||
// Possession of the recovery document restores access even at the device
|
||||
// limit. Lost devices lose authorization and cannot read future changes.
|
||||
atomic(() => {
|
||||
run('UPDATE devices SET revoked=1 WHERE vault=?', vault.id);
|
||||
run('DELETE FROM invitations WHERE vault=?', vault.id);
|
||||
addDevice(vault.id, body.device, body.label, body.envelope);
|
||||
run('UPDATE vaults SET epoch=?, recovery_envelope=?, owner=?, role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), body.device.id, vault.id);
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
if (path.endsWith('/result') && req.method === 'GET') {
|
||||
const invite = liveInvite(path.split('/').at(-2), true);
|
||||
if (!invite.approved) return { waiting: true };
|
||||
const applicant = parse(invite.applicant);
|
||||
const added = get('SELECT * FROM devices WHERE id=? AND revoked=0', applicant.id);
|
||||
if (!added) fail(410, 'Appareil retiré.');
|
||||
return { vault: invite.vault, envelope: parse(added.envelope) };
|
||||
}
|
||||
if (!device || !vault) fail(403, 'Action non autorisée.');
|
||||
if (path === `${PREFIX}/state` && req.method === 'GET') return {
|
||||
vault: vault.id, epoch: vault.epoch, owner: vault.owner, roleVersion: vault.role_version,
|
||||
envelope: parse(device.envelope), devices: all('SELECT * FROM devices WHERE vault=?', vault.id).map(d => ({ id: d.id, publicKey: parse(d.pub), wrapPublic: d.wrap, label: parse(d.label), revoked: !!d.revoked, seen: d.seen })),
|
||||
};
|
||||
if (path === `${PREFIX}/role` && req.method === 'POST') {
|
||||
if (body.expected !== vault.role_version) fail(409, 'Le responsable a changé. Actualisez avant de réessayer.');
|
||||
run('UPDATE vaults SET owner=?, role_version=role_version+1 WHERE id=?', device.id, vault.id);
|
||||
return { owner: device.id, roleVersion: vault.role_version + 1 };
|
||||
}
|
||||
if (path === `${PREFIX}/revisions` && req.method === 'GET') {
|
||||
const after = Number(url.searchParams.get('after') ?? 0);
|
||||
if (!Number.isSafeInteger(after) || after < 0) fail(400, 'Curseur invalide.');
|
||||
const records = all('SELECT seq,data FROM revisions WHERE vault=? AND seq>? ORDER BY seq LIMIT 100', vault.id, after);
|
||||
return { records: records.map(r => ({ seq: r.seq, ...parse(r.data) })), more: records.length === 100 };
|
||||
}
|
||||
if (path === `${PREFIX}/revisions` && req.method === 'POST') {
|
||||
const v = body;
|
||||
if (v.v !== 1 || v.vault !== vault.id || v.device !== device.id || !uuid(v.id) || typeof v.object !== 'string' || !/^[\w-]{43}$/.test(v.object) || !Array.isArray(v.parents) || v.parents.length > 100 || !v.parents.every(uuid) || new Set(v.parents).size !== v.parents.length || !envelope(v)) fail(400, 'Révision invalide.');
|
||||
const signed = { ...revisionHeader(v), iv: v.iv, ciphertext: v.ciphertext };
|
||||
if (!await verify(parse(device.pub), signed, v.signature)) fail(401, 'Révision non signée.');
|
||||
const existing = get('SELECT * FROM revisions WHERE vault=? AND id=?', vault.id, v.id);
|
||||
if (existing) {
|
||||
const old = parse(existing.data);
|
||||
if (old.device !== device.id || old.object !== v.object || canonical(old.parents) !== canonical(v.parents)) fail(409, 'Identifiant déjà utilisé.');
|
||||
return { seq: existing.seq };
|
||||
}
|
||||
if (!get('SELECT id FROM devices WHERE id=? AND revoked=0', device.id)) fail(403, 'Appareil révoqué.', 'DEVICE_REVOKED');
|
||||
if (v.epoch !== get('SELECT epoch FROM vaults WHERE id=?', vault.id)?.epoch) fail(409, 'Les clés ont changé. Relancez la synchronisation.');
|
||||
for (const parent of v.parents) if (!get('SELECT id FROM revisions WHERE vault=? AND id=? AND object=?', vault.id, parent, v.object)) fail(409, 'Révision parente manquante.');
|
||||
const total = get('SELECT coalesce(sum(size),0) bytes, count(*) n FROM revisions WHERE vault=?', vault.id);
|
||||
if (total.bytes + raw.length > maxBytes || total.n >= 100000) fail(507, 'Quota du coffre atteint. Exportez vos données et contactez l’hébergeur.');
|
||||
const stored = { ...signed, signature: v.signature };
|
||||
const result = run('INSERT INTO revisions(vault,id,object,data,size) VALUES(?,?,?,?,?)', vault.id, v.id, v.object, json(stored), Buffer.byteLength(raw));
|
||||
return { seq: Number(result.lastInsertRowid) };
|
||||
}
|
||||
if (path === `${PREFIX}/invitations` && req.method === 'POST') {
|
||||
limit(`invite:${device.id}`, 10);
|
||||
run('DELETE FROM invitations WHERE expires<? OR sponsor=?', now(), device.id);
|
||||
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
let code;
|
||||
do { code = Array.from(randomBytes(8), n => alphabet[n % alphabet.length]).join(''); } while (get('SELECT code FROM invitations WHERE code=?', code));
|
||||
run('INSERT INTO invitations(code,vault,sponsor,expires,lease) VALUES(?,?,?,?,?)', code, vault.id, device.id, now() + 300000, now() + 25000);
|
||||
return { code, expires: now() + 300000, sponsor: { id: device.id, publicKey: parse(device.pub), wrapPublic: device.wrap } };
|
||||
}
|
||||
const match = path.match(/^\/api\/sync\/invitations\/([A-Z2-9]{8})(?:\/(approve|heartbeat))?$/);
|
||||
if (match) {
|
||||
const invite = liveInvite(match[1], true);
|
||||
if (invite.sponsor !== device.id) fail(403, 'Invitation d’un autre appareil.');
|
||||
if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; }
|
||||
if (match[2] === 'heartbeat' && req.method === 'POST') {
|
||||
if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code);
|
||||
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved };
|
||||
}
|
||||
if (match[2] === 'approve' && req.method === 'POST') {
|
||||
if (invite.approved) return { ok: true };
|
||||
if (!invite.applicant || body.epoch !== vault.epoch || canonical(body.device) !== canonical(parse(invite.applicant))) fail(409, 'Demande modifiée. Recommencez l’association.');
|
||||
atomic(() => { addDevice(vault.id, body.device, body.label, body.envelope); run('UPDATE invitations SET approved=1 WHERE code=?', invite.code); });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
if ([`${PREFIX}/revoke`, `${PREFIX}/recovery-reset`].includes(path) && req.method === 'POST') {
|
||||
if (body.target === device.id) fail(400, 'Utilisez un autre appareil pour révoquer celui-ci.');
|
||||
const reset = path.endsWith('/recovery-reset');
|
||||
if (reset && (body.target !== null || !body.recoveryPublic || body.recoveryPublic.d || body.recoveryPublic.kty !== 'EC')) fail(400, 'Secours invalide.');
|
||||
const target = reset || get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.target, vault.id);
|
||||
if (!target || body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Appareils ou clés modifiés. Actualisez la liste.');
|
||||
const remaining = all('SELECT id FROM devices WHERE vault=? AND revoked=0 AND id<>?', vault.id, body.target ?? '');
|
||||
if (!body.envelopes || Object.keys(body.envelopes).length !== remaining.length || remaining.some(d => !envelope(body.envelopes[d.id]))) fail(400, 'Clés manquantes pour un appareil.');
|
||||
atomic(() => {
|
||||
if (!reset) run('UPDATE devices SET revoked=1 WHERE id=?', body.target);
|
||||
else run('UPDATE vaults SET recovery_pub=? WHERE id=?', json(body.recoveryPublic), vault.id);
|
||||
run('DELETE FROM invitations WHERE vault=?', vault.id);
|
||||
for (const d of remaining) run('UPDATE devices SET envelope=? WHERE id=?', json(body.envelopes[d.id]), d.id);
|
||||
run('UPDATE vaults SET epoch=?,recovery_envelope=?,owner=?,role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), vault.owner === body.target ? device.id : vault.owner, vault.id);
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === `${PREFIX}/vault` && req.method === 'DELETE') { run('DELETE FROM vaults WHERE id=?', vault.id); return { ok: true }; }
|
||||
fail(404, 'Route inconnue.');
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
try {
|
||||
if (origin && req.headers.origin && req.headers.origin !== origin) fail(403, 'Origine refusée.');
|
||||
if (req.headers['sec-fetch-site'] === 'cross-site') fail(403, 'Origine refusée.');
|
||||
const ip = trustProxy ? String(req.headers['x-pardis-client-ip'] ?? req.socket.remoteAddress) : req.socket.remoteAddress;
|
||||
limit(`all:${ip}`, 600);
|
||||
if (req.method !== 'GET' && req.headers['content-type'] !== 'application/json') fail(415, 'JSON requis.');
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 2 * 1024 * 1024) fail(413, 'Requête trop volumineuse.'); chunks.push(chunk); }
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
let body = {};
|
||||
try { if (raw) body = parse(raw); } catch { fail(400, 'JSON invalide.'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'Objet JSON requis.');
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (!url.pathname.startsWith(PREFIX)) fail(404, 'Route inconnue.');
|
||||
const result = await route(req, url, raw, body, ip);
|
||||
res.end(json(result));
|
||||
} catch (error) {
|
||||
res.statusCode = error.status ?? (String(error.code).startsWith('ERR_SQLITE') ? 409 : 500);
|
||||
res.end(json({ error: error.status ? error.message : 'Opération impossible. Réessayez.', ...(error.code === 'DEVICE_REVOKED' ? { code: error.code } : {}) }));
|
||||
}
|
||||
});
|
||||
server.requestTimeout = 15000; server.headersTimeout = 10000;
|
||||
server.on('close', () => db.close());
|
||||
return server;
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
9
server/backup.js
Normal file
9
server/backup.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Online SQLite backup includes committed WAL transactions. Run in the service
|
||||
// container, then copy the resulting file to independent, protected storage.
|
||||
import { DatabaseSync, backup } from 'node:sqlite';
|
||||
const source = process.env.SYNC_DB ?? './sync-data/pardis.sqlite';
|
||||
const destination = process.argv[2];
|
||||
if (!destination || destination === source) throw new Error('Usage: node server/backup.js /data/backup.sqlite (distinct destination)');
|
||||
const db = new DatabaseSync(source, { readOnly: true });
|
||||
try { await backup(db, destination); console.info('Encrypted database backup completed.'); }
|
||||
finally { db.close(); }
|
||||
8
server/index.js
Normal file
8
server/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createSyncServer } from './app.js';
|
||||
const filename = process.env.SYNC_DB ?? './sync-data/pardis.sqlite';
|
||||
mkdirSync(dirname(filename), { recursive: true });
|
||||
const server = createSyncServer({ filename, origin: process.env.SYNC_ORIGIN ?? '', trustProxy: process.env.SYNC_TRUST_PROXY === '1', maxBytes: Number(process.env.SYNC_MAX_BYTES ?? 52428800), maxVaults: Number(process.env.SYNC_MAX_VAULTS ?? 10000) });
|
||||
server.listen(Number(process.env.PORT ?? 3001), process.env.SYNC_HOST ?? '127.0.0.1', () => console.info('Pardis sync listening'));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
|
||||
Reference in New Issue
Block a user