257 lines
19 KiB
JavaScript
257 lines
19 KiB
JavaScript
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, expires: invite.expires };
|
||
}
|
||
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;
|
||
}
|