227 lines
15 KiB
JavaScript
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();
|
|
}
|