334 lines
13 KiB
JavaScript
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();
|
|
}
|