aere-quantum/pq-kms/server.mjs

334 lines
13 KiB
JavaScript

// 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 <token>"
// 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();
}