// 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 /aere-audit.log (jurnalul sidecar-ului) // KMS-ul, exact unul din doua: // AERE_KMS_ROOT_KEY [+ AERE_KMS_DATA_DIR, implicit /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(); }