// server.mjs - serverul HTTP al Aere PQ KMS (node:http, fara dependinte). // // Mediu: // AERE_KMS_ROOT_KEY 64 de caractere hexa (32 de octeti), SAU: // AERE_KMS_ROOT_HSM calea radacinii SIGILATE de un HSM (hsm-radacina.mjs), deschisa la pornire cu PIN-ul din AERE_HSM_PIN; // exact una din cele doua; fara niciuna refuza pornirea, cu amandoua refuza (ROOT_KEY_AMBIGUOUS) // AERE_KMS_TOKEN obligatoriu, cel putin 32 de caractere; se cere ca "Authorization: Bearer " // AERE_KMS_HOST implicit 127.0.0.1 // AERE_KMS_PORT implicit 8420 // AERE_KMS_MAX_BODY implicit 65536 (octeti) // AERE_KMS_DATA_DIR implicit ./data langa acest fisier // // Nicio valoare din mediu nu se tipareste. Raspunsurile de eroare sunt { error: COD, message }. import http from 'node:http'; import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { openKms, parseRootKey, KmsError } from './kms.mjs'; import { radacinaDinHsm } from './hsm-radacina.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const STATUS = { NOT_FOUND: 404, KEY_NOT_FOUND: 404, METHOD_NOT_ALLOWED: 405, KEY_EXISTS: 409, KEY_NOT_EXPORTABLE: 403, UNAUTHENTICATED: 401, INVALID_TOKEN: 401, BODY_TOO_LARGE: 413, UNSUPPORTED_MEDIA_TYPE: 415, INTERNAL: 500, AUDIT_WRITE_FAILED: 500, SEAL_AUTH_FAILED: 500, KEY_FILE_TAMPERED: 500, KMS_CLOSED: 503, }; class HttpError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } } export function configFromEnv(env, optiuniHsm = {}) { const token = String(env.AERE_KMS_TOKEN ?? '').trim(); if (token === '') throw new KmsError('TOKEN_MISSING', 'AERE_KMS_TOKEN is not set; refusing to start'); if (token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', `AERE_KMS_TOKEN must be at least 32 characters; the value given has ${token.length}`); if (!/^[\x21-\x7e]+$/.test(token)) throw new KmsError('TOKEN_INVALID', 'AERE_KMS_TOKEN must be printable ASCII without spaces'); // Validam cheia radacina aici, ca serverul sa nu porneasca deloc fara ea. Din 2026-09-28 poate veni si SIGILATA de un HSM. const inClar = String(env.AERE_KMS_ROOT_KEY ?? '').trim() !== '', dinHsm = String(env.AERE_KMS_ROOT_HSM ?? '').trim() !== ''; if (inClar && dinHsm) throw new KmsError('ROOT_KEY_AMBIGUOUS', 'set AERE_KMS_ROOT_KEY or AERE_KMS_ROOT_HSM, not both'); const rootHex = dinHsm ? radacinaDinHsm(env, optiuniHsm) : env.AERE_KMS_ROOT_KEY; const root = parseRootKey(rootHex); const sameAsRoot = token.toLowerCase() === root.toString('hex'); root.fill(0); if (sameAsRoot) throw new KmsError('TOKEN_EQUALS_ROOT_KEY', 'AERE_KMS_TOKEN must not be the root key'); const host = String(env.AERE_KMS_HOST ?? '').trim() || '127.0.0.1'; const portRaw = String(env.AERE_KMS_PORT ?? '').trim() || '8420'; const port = Number(portRaw); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new KmsError('INVALID_PORT', 'AERE_KMS_PORT must be an integer between 0 and 65535'); const maxRaw = String(env.AERE_KMS_MAX_BODY ?? '').trim() || '65536'; const maxBody = Number(maxRaw); // Sub 8192 nu incape nici o cerere de verificare (semnatura hibrida are 4531 de caractere base64). if (!Number.isInteger(maxBody) || maxBody < 8192 || maxBody > 16 * 1024 * 1024) { throw new KmsError('INVALID_MAX_BODY', 'AERE_KMS_MAX_BODY must be an integer between 8192 and 16777216'); } const dataDir = String(env.AERE_KMS_DATA_DIR ?? '').trim() || path.join(HERE, 'data'); return { token, rootKey: rootHex, rootSource: dinHsm ? 'hsm' : 'env', host, port, maxBody, dataDir }; } function tooBig(n, maxBody) { return n > maxBody; } function authCheck(req, tokenHash) { const header = req.headers['authorization']; if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED'; const m = /^Bearer ([\x21-\x7e]+)$/.exec(header); if (!m) return 'INVALID_TOKEN'; const got = crypto.createHash('sha256').update(m[1], 'utf8').digest(); if (!crypto.timingSafeEqual(got, tokenHash)) return 'INVALID_TOKEN'; return null; } // Arunca restul corpului fara sa-l tina in memorie; peste plafon inchide conexiunea. 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 (tooBig(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 (tooBig(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, { optional = false } = {}) { const v = obj[field]; if (v === undefined || v === null) { if (optional) return undefined; 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; } function strField(obj, field) { const v = obj[field]; if (typeof v !== 'string') throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required and must be a string`); return v; } export function createServer({ kms, token, maxBody = 65536 }) { if (typeof token !== 'string' || token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', 'token must be at least 32 characters'); const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest(); 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 }); } // Autentificarea se face INAINTE de a citi corpul. const authErr = authCheck(req, tokenHash); if (authErr) { discard(req, drainCap); return send(res, 401, { error: authErr, message: authErr === 'UNAUTHENTICATED' ? 'missing bearer token' : 'invalid bearer token' }, true); } let body = null; if (method === 'POST') { 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'); } else { discard(req, drainCap); } const name = parts[2] !== undefined ? decodeURIComponent(parts[2]) : undefined; if (parts[0] !== 'v1') throw new HttpError(404, 'NOT_FOUND', 'no such route'); // /v1/keys if (parts[1] === 'keys') { if (parts.length === 2 && method === 'GET') return send(res, 200, { keys: kms.listKeys() }); if (parts.length === 3 && method === 'POST') { const exportable = body.exportable === undefined ? false : body.exportable; return send(res, 200, kms.createKey(name, { type: body.type, exportable })); } if (parts.length === 3 && method === 'GET') return send(res, 200, kms.getKey(name)); if (parts.length === 4 && parts[3] === 'rotate' && method === 'POST') return send(res, 200, kms.rotate(name)); if (parts.length === 4 && parts[3] === 'config' && method === 'POST') { return send(res, 200, kms.setMinDecryptionVersion(name, body.min_decryption_version)); } if ((parts.length === 4 || parts.length === 5) && parts[3] === 'export' && method === 'GET') { let ver; if (parts.length === 5) { if (!/^[1-9][0-9]{0,8}$/.test(parts[4])) throw new HttpError(400, 'INVALID_VERSION', 'version must be a positive integer'); ver = Number(parts[4]); } return send(res, 200, kms.exportKey(name, ver)); } } if (parts.length === 3 && method === 'POST') { switch (parts[1]) { case 'encrypt': { const r = kms.encrypt(name, b64Field(body, 'plaintext'), b64Field(body, 'aad', { optional: true })); return send(res, 200, r); } case 'decrypt': { const r = kms.decrypt(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true })); const out = { plaintext: r.plaintext.toString('base64'), version: r.version }; r.plaintext.fill(0); return send(res, 200, out); } case 'rewrap': return send(res, 200, kms.rewrap(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true }))); case 'datakey': { const includePlaintext = body.include_plaintext === undefined ? true : body.include_plaintext; const bits = body.bits === undefined ? 256 : body.bits; return send(res, 200, kms.datakey(name, { aad: b64Field(body, 'aad', { optional: true }), bits, includePlaintext })); } case 'sign': return send(res, 200, kms.sign(name, b64Field(body, 'message'))); case 'verify': return send(res, 200, kms.verify(name, b64Field(body, 'message'), strField(body, 'signature'))); default: break; } } if (parts.length === 3 && parts[1] === 'audit' && parts[2] === 'verify' && method === 'GET') { return send(res, 200, kms.verifyAudit()); } 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 KmsError) return send(res, STATUS[e.code] ?? 400, { error: e.code, message: e.message }); if (e instanceof URIError) return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid percent-encoding in path' }); return send(res, 500, { error: 'INTERNAL', message: 'internal error' }); }); }); server.headersTimeout = 10_000; server.requestTimeout = 30_000; return server; } async function main() { let cfg; let kms; try { cfg = configFromEnv(process.env); kms = openKms({ dataDir: cfg.dataDir, rootKey: cfg.rootKey }); } catch (e) { const code = e instanceof KmsError ? e.code : 'INTERNAL'; const msg = e instanceof KmsError ? e.message : 'internal error'; process.stderr.write(`aere-pq-kms: refusing to start: ${code}: ${msg}\n`); process.exitCode = 1; return; } const server = createServer({ kms, token: cfg.token, maxBody: cfg.maxBody }); server.on('error', (e) => { process.stderr.write(`aere-pq-kms: server error: ${e.code ?? 'ERROR'}\n`); process.exitCode = 1; }); server.listen(cfg.port, cfg.host, () => { const a = server.address(); process.stdout.write(`aere-pq-kms: listening on ${a.address}:${a.port}\n`); }); const stop = () => { server.close(() => 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(); }