aere-quantum/pq-secrets/server.mjs

227 lines
15 KiB
JavaScript

// server.mjs - serverul HTTP al Aere PQ Secrets (node:http, fara dependinte). Toate operatiile sunt POST cu JSON, fiindca fiecare
// cerere poarta o PREZENTARE de credential AERE (zeci de KB cu semnaturile hibride), care nu incape intr-un antet.
//
// Mediu:
// AERE_SECRETS_AUDIENCE obligatoriu: numele sub care se fac prezentarile pentru acest serviciu (ex. https://secrets.example)
// AERE_SECRETS_ADMIN_POLICY obligatoriu: fisier JSON cu politica de conformitate a adminului (id, trustedIssuers, require)
// AERE_SECRETS_DATA_DIR implicit ./data langa acest fisier (service.json, secrets/, aere-audit.log)
// AERE_SECRETS_TRUST_DIR implicit ./trust langa acest fisier (status-lists/*.json, revocations/*.json, citite la fiecare cerere)
// AERE_SECRETS_AUDIT_LOG implicit <data>/aere-audit.log (jurnalul sidecar-ului)
// KMS-ul, exact unul din doua:
// AERE_KMS_ROOT_KEY [+ AERE_KMS_DATA_DIR, implicit <data>/kms] KMS-ul in acest proces (pq-kms/kms.mjs)
// AERE_KMS_URL + AERE_KMS_TOKEN serverul pq-kms, prin HTTP
// AERE_SECRETS_KMS_KEY implicit secrets (cheia KMS de tip encrypt; creata daca lipseste)
// AERE_SECRETS_HOST implicit 127.0.0.1
// AERE_SECRETS_PORT implicit 8430 (0 alege unul liber)
// AERE_SECRETS_MAX_BODY implicit 262144 (octeti), 65536..16777216
// AERE_SECRETS_NONCE_TTL implicit 120 (secunde)
// AERE_SECRETS_NONCE_PER_ORIGIN implicit 32: nonce-uri in zbor pe o adresa de origine (peste: 429 NONCE_ORIGIN_FULL)
// AERE_SECRETS_STALE_LOCK implicit refuse; take = preia service.lock-ul unui proces care nu mai ruleaza pe gazda (si o spune)
//
// Nicio valoare din mediu, niciun secret, niciun nonce si nicio cheie nu se tipareste. Raspunsurile de eroare sunt { error, rule, message, ... }.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { openKms, KmsError } from '../pq-kms/kms.mjs';
import { openSecrets, kmsClientHttp, SecretsError, OPERATII_GRANT, NONCE_MAX } from './secrete.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
// codul HTTP se alege dupa REGULA refuzului (un cod dupa fiecare cod de eroare ar fi o a doua lista, care diverge)
const STATUS_REGULA = {
'request-form': 400, 'secret-name': 400, 'value-form': 400, 'grant-form': 400,
'presentation-form': 401, nonce: 401,
authorization: 403, 'version-scope': 403, 'grant-scope': 403,
'grant-limit': 409,
'secret-exists': 404, 'version-exists': 404,
'version-revoked': 410,
kms: 502, storage: 500, internal: 500,
};
const STATUS_COD = { GRANT_EXISTS: 409, GRANT_NOT_FOUND: 404, AUDIT_WRITE_FAILED: 500, SERVICE_CLOSED: 503, NONCE_STORE_FULL: 503, NONCE_ORIGIN_FULL: 429, SECRET_FILE_TAMPERED: 500 };
class HttpError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
}
export function configFromEnv(env) {
const s = (k, d = '') => String(env[k] ?? '').trim() || d;
const audience = s('AERE_SECRETS_AUDIENCE');
if (audience === '') throw new SecretsError('AUDIENCE_MISSING', 'AERE_SECRETS_AUDIENCE is not set; refusing to start (presentations must name the service they are made for)');
const adminFile = s('AERE_SECRETS_ADMIN_POLICY');
if (adminFile === '') throw new SecretsError('ADMIN_POLICY_MISSING', 'AERE_SECRETS_ADMIN_POLICY is not set; refusing to start (a service with no admin policy cannot create a secret)');
let adminPolicy;
try { adminPolicy = JSON.parse(fs.readFileSync(adminFile, 'utf8')); } catch { throw new SecretsError('ADMIN_POLICY_UNREADABLE', 'AERE_SECRETS_ADMIN_POLICY does not name a readable JSON file'); }
const dataDir = s('AERE_SECRETS_DATA_DIR', path.join(HERE, 'data'));
const trustDir = s('AERE_SECRETS_TRUST_DIR', path.join(HERE, 'trust'));
const auditLog = s('AERE_SECRETS_AUDIT_LOG', path.join(dataDir, 'aere-audit.log'));
const inProces = s('AERE_KMS_ROOT_KEY') !== '', laDistanta = s('AERE_KMS_URL') !== '';
if (inProces && laDistanta) throw new SecretsError('KMS_AMBIGUOUS', 'set AERE_KMS_ROOT_KEY (KMS in this process) or AERE_KMS_URL (remote KMS), not both');
if (!inProces && !laDistanta) throw new SecretsError('KMS_MISSING', 'set AERE_KMS_ROOT_KEY (KMS in this process) or AERE_KMS_URL + AERE_KMS_TOKEN (remote KMS); refusing to start');
const kms = inProces ? { mode: 'in-process', rootKey: env.AERE_KMS_ROOT_KEY, dataDir: s('AERE_KMS_DATA_DIR', path.join(dataDir, 'kms')) } : { mode: 'http', url: s('AERE_KMS_URL'), token: s('AERE_KMS_TOKEN') };
const kmsKey = s('AERE_SECRETS_KMS_KEY', 'secrets');
const host = s('AERE_SECRETS_HOST', '127.0.0.1');
const port = Number(s('AERE_SECRETS_PORT', '8430'));
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new SecretsError('INVALID_PORT', 'AERE_SECRETS_PORT must be an integer between 0 and 65535');
const maxBody = Number(s('AERE_SECRETS_MAX_BODY', '262144'));
if (!Number.isInteger(maxBody) || maxBody < 65536 || maxBody > 16 * 1024 * 1024) throw new SecretsError('INVALID_MAX_BODY', 'AERE_SECRETS_MAX_BODY must be an integer between 65536 and 16777216');
const nonceTtlS = Number(s('AERE_SECRETS_NONCE_TTL', '120'));
if (!Number.isInteger(nonceTtlS) || nonceTtlS < 5 || nonceTtlS > 3600) throw new SecretsError('INVALID_NONCE_TTL', 'AERE_SECRETS_NONCE_TTL must be an integer between 5 and 3600');
const noncePerOrigin = Number(s('AERE_SECRETS_NONCE_PER_ORIGIN', '32'));
if (!Number.isInteger(noncePerOrigin) || noncePerOrigin < 1 || noncePerOrigin > NONCE_MAX) throw new SecretsError('INVALID_NONCE_PER_ORIGIN', `AERE_SECRETS_NONCE_PER_ORIGIN must be an integer between 1 and ${NONCE_MAX}`);
const staleLock = s('AERE_SECRETS_STALE_LOCK', 'refuse');
if (staleLock !== 'refuse' && staleLock !== 'take') throw new SecretsError('INVALID_STALE_LOCK', 'AERE_SECRETS_STALE_LOCK is refuse (default) or take');
return { audience, adminPolicy, dataDir, trustDir, auditLog, kms, kmsKey, host, port, maxBody, nonceTtlS, noncePerOrigin, staleLock };
}
/** Deschide KMS-ul si serviciul din configuratie (folosit de main si de probe). */
export async function openFromConfig(cfg) {
const kms = cfg.kms.mode === 'in-process' ? openKms({ dataDir: cfg.kms.dataDir, rootKey: cfg.kms.rootKey }) : kmsClientHttp({ url: cfg.kms.url, token: cfg.kms.token });
const secrets = await openSecrets({ dataDir: cfg.dataDir, kms, audience: cfg.audience, adminPolicy: cfg.adminPolicy, trustDir: cfg.trustDir, auditLog: cfg.auditLog, kmsKey: cfg.kmsKey, nonceTtlS: cfg.nonceTtlS,
...(cfg.noncePerOrigin ? { noncePerOrigin: cfg.noncePerOrigin } : {}), ...(cfg.staleLock ? { staleLock: cfg.staleLock } : {}) });
return { kms, secrets };
}
function discard(req, cap) {
let n = 0;
req.on('data', (c) => { n += c.length; if (n > cap) req.destroy(); });
req.on('error', () => {});
req.resume();
}
function readBody(req, maxBody) {
return new Promise((resolve, reject) => {
const declared = req.headers['content-length'];
if (declared !== undefined) {
const n = Number(declared);
if (!Number.isSafeInteger(n) || n < 0) return reject(new HttpError(400, 'INVALID_CONTENT_LENGTH', 'invalid Content-Length'));
if (n > maxBody) return reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`));
}
const chunks = []; let size = 0; let done = false;
req.on('data', (c) => {
if (done) return;
size += c.length;
if (size > maxBody) { done = true; chunks.length = 0; reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`)); return; }
chunks.push(c);
});
req.on('end', () => { if (!done) { done = true; resolve(Buffer.concat(chunks)); } });
req.on('error', () => { if (!done) { done = true; reject(new HttpError(400, 'BAD_REQUEST', 'the request body could not be read')); } });
});
}
function send(res, status, obj, close = false) {
const body = JSON.stringify(obj);
const headers = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
if (close) headers.connection = 'close';
res.writeHead(status, headers);
res.end(body);
}
function b64Field(obj, field) {
const v = obj[field];
if (v === undefined || v === null) throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required`);
if (typeof v !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(v) || v.length % 4 !== 0) throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be standard base64`);
const b = Buffer.from(v, 'base64');
if (b.toString('base64') !== v) throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be canonical base64`);
return b;
}
// un refuz poarta { error, rule, message } si, numai cand prezentarea insasi nu e valida, identityRules (regulile identitatii nu spun
// nimic despre secret); grant-urile judecate si regulile lor stau in jurnal, nu in raspuns
const refuzJson = (e) => {
const out = { error: e.code, rule: e.rule ?? null, message: e.message };
if (Array.isArray(e.identityRules) && e.identityRules.length) out.identityRules = e.identityRules;
return out;
};
export function createServer({ secrets, maxBody = 262144 }) {
const drainCap = maxBody * 4 + 1024 * 1024;
async function handle(req, res) {
let url;
try { url = new URL(req.url, 'http://localhost'); } catch { discard(req, drainCap); return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid request URL' }, true); }
const method = req.method;
const parts = url.pathname.split('/').filter((p) => p !== '');
if (method === 'GET' && url.pathname === '/v1/health') { discard(req, drainCap); return send(res, 200, { ok: true, audience: secrets.audience }); }
if (method === 'GET' && url.pathname === '/v1/audit/head') { discard(req, drainCap); return send(res, 200, secrets.auditHead()); }
if (method !== 'POST') { discard(req, drainCap); throw new HttpError(405, 'METHOD_NOT_ALLOWED', 'this service takes POST requests with a JSON body (every request carries a presentation)'); }
let body;
{
const ctype = String(req.headers['content-type'] ?? '');
let raw;
try { raw = await readBody(req, maxBody); } catch (e) { discard(req, drainCap); throw e; }
if (raw.length > 0 && ctype !== '' && !/^application\/json\b/i.test(ctype)) throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'request body must be application/json');
if (raw.length === 0) body = {};
else { try { body = JSON.parse(raw.toString('utf8')); } catch { throw new HttpError(400, 'INVALID_JSON', 'request body is not valid JSON'); } }
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'INVALID_JSON', 'request body must be a JSON object');
}
if (parts[0] !== 'v1') throw new HttpError(404, 'NOT_FOUND', 'no such route');
// nonce-urile in zbor se numara pe adresa de la care vine cererea (in spatele unui proxy, adresa proxy-ului: spus in README)
if (parts.length === 2 && parts[1] === 'nonce') return send(res, 200, secrets.nonce({ origin: String(req.socket.remoteAddress || 'unknown') }));
if (parts.length === 2 && parts[1] === 'list') return send(res, 200, await secrets.list(body));
if (parts.length === 4 && parts[1] === 'secrets') {
const name = decodeURIComponent(parts[2]); const op = parts[3];
// corpul INTREG ajunge la biblioteca: digestul cererii (requestHash) acopera fiecare camp, deci un camp adaugat de cine nu a semnat
// prezentarea schimba digestul si cererea e refuzata (regula request a identitatii)
switch (op) {
case 'put': {
const value = b64Field(body, 'value');
const r = await secrets.put(name, { ...body, value });
value.fill(0);
return send(res, 200, r);
}
case 'get': {
const r = await secrets.get(name, body);
const out = { name: r.name, version: r.version, current: r.current, value: r.value.toString('base64'), metadata: r.metadata, createdAt: r.createdAt };
r.value.fill(0);
return send(res, 200, out);
}
case 'metadata': return send(res, 200, await secrets.metadata(name, body));
case 'rotate': return send(res, 200, await secrets.rotate(name, body));
case 'revoke-version': return send(res, 200, await secrets.revokeVersion(name, body));
case 'grant': return send(res, 200, await secrets.grant(name, body));
case 'revoke': return send(res, 200, await secrets.revoke(name, body));
default: throw new HttpError(404, 'NOT_FOUND', 'no such operation; one of: ' + OPERATII_GRANT.join(', '));
}
}
throw new HttpError(404, 'NOT_FOUND', 'no such route');
}
const server = http.createServer((req, res) => {
handle(req, res).catch((e) => {
if (res.headersSent) { res.destroy(); return; }
if (e instanceof HttpError) return send(res, e.status, { error: e.code, message: e.message }, e.status === 413);
if (e instanceof SecretsError) return send(res, STATUS_COD[e.code] ?? STATUS_REGULA[e.rule] ?? 400, refuzJson(e));
if (e instanceof KmsError) return send(res, 502, { error: 'KMS_' + e.code, rule: 'kms', message: 'the KMS refused: ' + e.code });
if (e instanceof URIError) return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid percent-encoding in path' });
return send(res, 500, { error: 'INTERNAL', rule: 'internal', message: 'internal error' });
});
});
server.headersTimeout = 10_000;
server.requestTimeout = 30_000;
return server;
}
async function main() {
let cfg, deschis;
try {
cfg = configFromEnv(process.env);
deschis = await openFromConfig(cfg);
} catch (e) {
const code = e instanceof SecretsError || e instanceof KmsError ? e.code : 'INTERNAL';
const msg = e instanceof SecretsError || e instanceof KmsError ? e.message : 'internal error';
process.stderr.write(`aere-pq-secrets: refusing to start: ${code}: ${msg}\n`);
process.exitCode = 1;
return;
}
const server = createServer({ secrets: deschis.secrets, maxBody: cfg.maxBody });
server.on('error', (e) => { process.stderr.write(`aere-pq-secrets: server error: ${e.code ?? 'ERROR'}\n`); process.exitCode = 1; });
if (deschis.secrets.tookStaleLock) process.stderr.write(`aere-pq-secrets: took over the stale lock of process ${deschis.secrets.tookStaleLock.pid} (AERE_SECRETS_STALE_LOCK=take)\n`);
server.listen(cfg.port, cfg.host, () => {
const a = server.address();
process.stdout.write(`aere-pq-secrets: listening on ${a.address}:${a.port} for audience ${cfg.audience} (KMS ${cfg.kms.mode})\n`);
});
const stop = () => { server.close(() => { deschis.secrets.close(); if (deschis.kms && typeof deschis.kms.close === 'function') deschis.kms.close(); }); };
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}