aere-quantum/readiness/readiness-service.mjs

193 lines
18 KiB
JavaScript

#!/usr/bin/env node
// AERE Quantum Readiness: scanerul public al pregatirii post-cuantice a unui domeniu. MASOARA, nu estimeaza:
// pentru gazda data, patru strangeri de mana TLS reale de pe gazda Cloud (EU) si o cerere HEAD:
// 1. TLS 1.3 cu grupurile obisnuite -> linia de baza: protocol, cifru, grupul clasic, certificatul si lantul lui
// 2. TLS 1.3 oferind NUMAI X25519MLKEM768 -> serverul stie schimbul de chei hibrid post-cuantic? (da/nu)
// 3. TLS 1.3 cu hibridul oferit primul + clasice -> il PREFERA cand i se ofera? (dedus: OpenSSL nu numeste grupul hibrid
// in getEphemeralKeyInfo, deci "reusit si fara nume" = ne-clasic; controlul
// pozitiv al metodei e ca grupurile clasice IES cu nume)
// 4. numai TLS 1.2 -> mai accepta o versiune fara schimb de chei post-cuantic?
// 5. HEAD https://gazda/ -> HSTS
// De aici: expunerea la "recolteaza acum, decripteaza mai tarziu" (fara schimb de chei PQ, tot traficul inregistrat azi se
// poate citi cand exista un calculator cuantic), autentificarea clasica a certificatului (azi TOATE certificatele WebPKI
// sunt ECDSA/RSA: se raporteaza, nu se penalizeaza, fiindca nu exista inca alternativa emisa de CA-uri), expirarea,
// TLS 1.2, HSTS; un scor si recomandari concrete. Cere Node cu OpenSSL >= 3.5 (grupurile ML-KEM); altfel refuza sa
// porneasca, in loc sa raporteze "fara PQ" despre toata lumea.
import http from 'node:http';
import tls from 'node:tls';
import dns from 'node:dns/promises';
import https from 'node:https';
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
import { ipPrivat, lookupFixat } from './adrese-private.mjs';
const PORT = Number(process.env.PORT || 8797);
const FROM = process.env.AERE_READINESS_FROM || 'AERE Cloud, EU';
const JURNAL_DIR = process.env.AERE_READINESS_DIR || '/var/lib/aere/readiness';
const ADOPTIE_DIR = process.env.AERE_ADOPTIE_DIR || '/var/lib/aere/readiness/adoptie';
const TTL_MS = 6 * 3600e3;
const PQ_GROUP = 'X25519MLKEM768';
const PQ_GROUP_2 = 'SecP256r1MLKEM768';
const [oMaj, oMin] = String(process.versions.openssl).split('.').map(Number);
if (oMaj < 3 || (oMaj === 3 && oMin < 5)) {
console.error(`REFUSED: OpenSSL ${process.versions.openssl} does not know the ML-KEM groups; 3.5 or later is required`);
process.exit(2);
}
try { mkdirSync(JURNAL_DIR, { recursive: true }); } catch {}
const cache = new Map(); // domeniu -> { at, report }
const ritm = new Map(); // ip -> [timestamps]
// B-16 (2026-09-29): coada de asteptare e MARGINITA; fara margine, cereri trimise mai repede decat se scaneaza tineau fiecare o
// conexiune si memorie la nesfarsit. Peste margine raspunsul e 503 busy, spus, nu o asteptare fara capat.
let inLucru = 0; const MAX_PARALEL = 4; const coada = []; export const MAX_COADA = 32;
const domeniuValid = (d) => /^(?=.{1,253}$)(?!-)([a-z0-9-]{1,63}\.)+[a-z]{2,63}$/i.test(d) && !/^\d+\.\d+\.\d+\.\d+$/.test(d);
function probe(host, opts, adresa, port = 443) {
return new Promise((res) => {
const t0 = Date.now();
let done = false;
const fin = (v) => { if (!done) { done = true; res(v); } };
let s;
try {
s = tls.connect({ host, port, servername: host, timeout: 8000, ALPNProtocols: ['h2', 'http/1.1'], rejectUnauthorized: false, ...(adresa ? { lookup: lookupFixat(adresa) } : {}), ...opts }, () => {
const c = s.getPeerCertificate(true) || {};
const e = s.getEphemeralKeyInfo() || {};
const chain = []; let x = c; const seen = new Set();
while (x && x.fingerprint256 && !seen.has(x.fingerprint256)) { seen.add(x.fingerprint256); chain.push({ subject: x.subject?.CN || null, issuer: x.issuer?.O || x.issuer?.CN || null, keyType: x.asn1Curve ? 'EC/' + x.asn1Curve : (x.bits ? 'RSA' : 'other'), bits: x.bits || null, validTo: x.valid_to || null }); if (!x.issuerCertificate || x.issuerCertificate === x) break; x = x.issuerCertificate; }
fin({ ok: true, ms: Date.now() - t0, protocol: s.getProtocol(), cipher: s.getCipher()?.name || null, groupName: e.name || null, alpn: s.alpnProtocol || null, authorized: s.authorized, authError: s.authorized ? null : (s.authorizationError ? String(s.authorizationError) : null), chain });
s.end();
});
} catch (e) { return fin({ ok: false, ms: Date.now() - t0, err: e.code || e.message }); }
s.on('error', (e) => fin({ ok: false, ms: Date.now() - t0, err: e.code || String(e.message).slice(0, 80) }));
s.on('timeout', () => { s.destroy(); fin({ ok: false, ms: Date.now() - t0, err: 'timeout' }); });
});
}
function head(host, adresa, port = 443) {
return new Promise((res) => {
const t0 = Date.now();
const req = https.request({ host, port, servername: host, path: '/', method: 'HEAD', timeout: 8000, ...(adresa ? { lookup: lookupFixat(adresa) } : {}), headers: { 'user-agent': 'aere-quantum-readiness/1 (+https://aere.network/quantum-readiness.html)' }, rejectUnauthorized: false }, (r) => {
res({ ok: true, status: r.statusCode, hsts: r.headers['strict-transport-security'] || null, ms: Date.now() - t0 }); r.resume();
});
req.on('error', (e) => res({ ok: false, err: e.code || String(e.message).slice(0, 60) })); req.on('timeout', () => { req.destroy(); res({ ok: false, err: 'timeout' }); }); req.end();
});
}
export async function scaneaza(domain, { rezolva = (d) => dns.lookup(d, { all: true }) } = {}) {
const measuredAt = new Date().toISOString();
let addrs;
try { addrs = await rezolva(domain); } catch (e) { return { domain, measuredAt, from: FROM, error: 'dns', detail: e.code || 'unresolvable' }; }
// GARDA (2026-09-18): scanerul e public si fara cheie. O gazda care se rezolva, fie si PARTIAL, la o adresa care nu e dovedit
// publica nu primeste NICIO conexiune si raspunsul nu ii spune adresele: altfel oricine isi indreapta un nume spre reteaua
// noastra interna si afla din raport ce porturi 443 sunt deschise acolo si ce certificate poarta (masurat pe serviciul viu:
// o gazda straina rezolvata la 127.0.0.1 si ::1 a fost sondata). Conexiunile merg apoi pe adresa VERIFICATA, nu pe nume:
// intre verificare si conexiune numele nu se mai rezolva a doua oara.
if (!Array.isArray(addrs) || !addrs.length) return { domain, measuredAt, from: FROM, error: 'dns', detail: 'unresolvable' };
if (addrs.some((a) => ipPrivat(a.address))) return { domain, measuredAt, from: FROM, error: 'private_target', detail: 'the hostname resolves to an address that is not public; nothing was connected to' };
const tinta = addrs.find((a) => a.family === 4) || addrs[0];
return scaneazaAdresa(domain, tinta, { measuredAt, addrs });
}
// scaneazaAdresa: masuratoarea propriu-zisa pe o adresa DEJA verificata. NU are garda de adrese private: serviciul HTTP cheama numai
// `scaneaza` (de mai sus), care o pune; aceasta e pentru apelantii care au verificat singuri tinta si pentru probele locale ale
// remedierii (2026-09-28, control-plane/remediere.mjs: un server TLS pe 127.0.0.1, pe alt port decat 443, fara jurnalul serviciului).
export async function scaneazaAdresa(domain, tinta, { port = 443, measuredAt = new Date().toISOString(), addrs = [tinta], jurnal = true } = {}) {
// linia de baza cere EXPLICIT grupurile clasice: grupul implicit al lui OpenSSL 3.5 pune hibridul primul, deci fara
// lista explicita chiar linia de baza ar negocia ML-KEM si controlul metodei (grupul clasic are nume) ar cadea
const base = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: 'X25519:P-256:P-384' }, tinta, port);
const modern = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3' }, tinta, port); // ce primeste un client OpenSSL 3.5 la zi
const pq1 = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: PQ_GROUP }, tinta, port);
const pq2 = pq1.ok ? null : await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: PQ_GROUP_2 }, tinta, port);
const pref = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: `${PQ_GROUP}:X25519:P-256` }, tinta, port);
const t12 = await probe(domain, { minVersion: 'TLSv1.2', maxVersion: 'TLSv1.2' }, tinta, port);
const h = await head(domain, tinta, port);
const tls13 = base.ok;
const pqKex = pq1.ok ? PQ_GROUP : (pq2 && pq2.ok ? PQ_GROUP_2 : null);
// controlul pozitiv al metodei de deducere: grupul clasic trebuie sa iasa CU nume; daca nu iese, deducerea nu e valida
const metodaOk = base.ok && typeof base.groupName === 'string' && base.groupName.length > 0;
const prefersPq = pqKex && pref.ok ? (metodaOk ? (pref.groupName === null) : null) : (pqKex ? false : null);
const leaf = (base.ok ? base : t12).chain?.[0] || null;
const daysLeft = leaf?.validTo ? Math.floor((Date.parse(leaf.validTo) - Date.now()) / 86400e3) : null;
const findings = []; let score = 100;
const add = (id, severity, title, detail, recommendation, penalty) => { findings.push({ id, severity, title, detail, recommendation }); score -= penalty; };
if (!tls13 && !t12.ok) return { domain, measuredAt, from: FROM, error: 'no_tls', detail: base.err || t12.err, addresses: addrs.map((a) => a.address) };
if (!tls13) add('tls13-missing', 'high', 'TLS 1.3 is not offered', `Only TLS 1.2 handshakes succeeded (${t12.err ? '' : t12.cipher}). Post-quantum key exchange exists only in TLS 1.3.`, 'Enable TLS 1.3 on the edge or load balancer; then enable a hybrid post-quantum group.', 15);
if (!pqKex) add('hndl-exposed', 'high', 'No post-quantum key exchange: harvest-now-decrypt-later exposure', `The server refused a TLS 1.3 handshake offering only ${PQ_GROUP} (${pq1.err || 'handshake failure'})${pq2 ? ` and ${PQ_GROUP_2} (${pq2.err || 'handshake failure'})` : ''}. Every session recorded today is decryptable once a cryptographically relevant quantum computer exists.`, `Enable the hybrid group ${PQ_GROUP} (X25519 + ML-KEM-768, FIPS 203). Cloudflare, Google, Amazon and modern OpenSSL/BoringSSL stacks support it; browsers already send it.`, 45);
else if (prefersPq === false) add('pq-not-preferred', 'medium', 'Post-quantum key exchange is supported but not preferred', `With ${PQ_GROUP} offered first alongside classical groups, the server picked ${pref.groupName || 'a classical group'}.`, 'Order the hybrid group first in the server preference list so every capable client gets it.', 10);
else if (prefersPq === null && pqKex) add('pq-preference-unmeasured', 'info', 'Post-quantum key exchange is supported; preference could not be inferred', 'The method control (a classical group must report its name) did not pass, so no claim is made about preference.', null, 0);
if (t12.ok) add('tls12-accepted', 'medium', 'TLS 1.2 is still accepted', `A TLS 1.2-only client was served (${t12.cipher}). Such sessions never get post-quantum key exchange.`, 'Retire TLS 1.2 once your client population allows it, or at least prefer TLS 1.3.', 10);
if (h.ok && !h.hsts) add('hsts-missing', 'low', 'No HSTS header', 'Strict-Transport-Security is absent on the front page, so a first visit can be downgraded to plaintext.', 'Send Strict-Transport-Security with a max-age of at least one year.', 5);
if (leaf) {
if (daysLeft !== null && daysLeft < 7) add('cert-expiring', 'high', 'Certificate expires in less than 7 days', `Leaf certificate valid to ${leaf.validTo}.`, 'Renew now and automate renewal.', 20);
else if (daysLeft !== null && daysLeft < 30) add('cert-expiring', 'medium', 'Certificate expires in less than 30 days', `Leaf certificate valid to ${leaf.validTo}.`, 'Automate renewal (ACME) so rotation never depends on a person.', 10);
if (/^RSA$/.test(leaf.keyType) && leaf.bits && leaf.bits < 3072) add('rsa-short', 'low', `RSA-${leaf.bits} leaf key`, 'Below the 3072-bit size recommended for keys living past 2030 (classical strength).', 'Move to ECDSA P-256/P-384 or RSA-3072+ at the next issuance.', 5);
add('auth-classical', 'info', `Certificate authentication is classical (${leaf.keyType}${leaf.bits ? '-' + leaf.bits : ''})`, 'Every public web certificate today is signed with ECDSA or RSA; a quantum adversary could forge such signatures in the future, but unlike encryption this cannot be exploited retroactively on recorded traffic.', 'Keep certificate agility: short-lived, automatically issued certificates, so switching to hybrid or post-quantum certificates is a configuration change when CAs offer them.', 0);
}
if (!base.authorized && base.ok) add('chain-untrusted', 'medium', 'Certificate chain not trusted by a standard root store', String(base.authError || ''), 'Serve the full intermediate chain from a publicly trusted CA.', 10);
score = Math.max(0, score);
const verdict = score >= 80 ? 'post-quantum key exchange in place' : score >= 50 ? 'partially prepared' : 'exposed';
const report = {
domain, measuredAt, from: FROM, addresses: addrs.map((a) => a.address), score, verdict,
summary: { tls13, pqKeyExchange: pqKex, prefersPqWhenOffered: prefersPq, tls12Accepted: t12.ok, hsts: h.ok ? !!h.hsts : null, harvestNowDecryptLater: pqKex ? 'protected for TLS 1.3 clients that offer the hybrid group' : 'exposed', certificate: leaf ? { keyType: leaf.keyType, bits: leaf.bits, issuer: leaf.issuer, validTo: leaf.validTo, daysLeft } : null },
handshakes: { classicalBaseline: base, modernClientDefault: modern, pqOnly: pq1, pqOnlyAlt: pq2, pqPreferredOffer: pref, tls12Only: t12, head: h },
findings,
method: 'Five real connections from the AERE Cloud host (EU): a TLS 1.3 baseline; TLS 1.3 offering only X25519MLKEM768 (then SecP256r1MLKEM768); TLS 1.3 offering the hybrid group first with classical fallbacks (preference inferred, with a method control); TLS 1.2 only; and an HTTPS HEAD for HSTS. Certificate facts come from the served chain. This measures the public TLS edge of one hostname; it does not measure your applications, keys at rest, internal services or code, which a full quantum readiness assessment covers.',
};
if (jurnal) try { appendFileSync(`${JURNAL_DIR}/scanari.jsonl`, JSON.stringify({ t: measuredAt, domain, score, pqKex: !!pqKex }) + '\n'); } catch {}
return report;
}
function ritmOk(ip) {
const now = Date.now(); const l = (ritm.get(ip) || []).filter((t) => now - t < 60e3);
if (l.length >= 12) { ritm.set(ip, l); return false; }
l.push(now); ritm.set(ip, l); return true;
}
function json(res, cod, obj) { const b = JSON.stringify(obj); res.writeHead(cod, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(b), 'cache-control': 'no-store' }); res.end(b); }
const corp = (req) => new Promise((res, rej) => { let s = ''; req.on('data', (d) => { s += d; if (s.length > 4096) { rej(new Error('too_big')); req.destroy(); } }); req.on('end', () => res(s)); req.on('error', rej); });
export async function cuLimita(fn) {
if (inLucru >= MAX_PARALEL) {
if (coada.length >= MAX_COADA) { const e = new Error('busy'); e.busy = true; throw e; }
await new Promise((r) => coada.push(r));
}
inLucru++;
try { return await fn(); } finally { inLucru--; const n = coada.shift(); if (n) n(); }
}
const RULAT_DIRECT = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/readiness-service.mjs');
if (RULAT_DIRECT) http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, openssl: process.versions.openssl, pqGroup: PQ_GROUP, cached: cache.size, inProgress: inLucru });
// seria de adoptie PQ (adoptie-pq.mjs): editia cea mai noua sau una datata; numai agregatul public, niciodata dosarul privat
const editie = /^\/adoption(?:\/(\d{4}-\d{2}-\d{2}))?$/.exec(url.pathname);
if (req.method === 'GET' && editie) {
try { return json(res, 200, JSON.parse(readFileSync(`${ADOPTIE_DIR}/${editie[1] || 'latest'}.json`, 'utf8'))); }
catch { return json(res, 404, { error: 'no_edition', hint: editie[1] ? 'no edition was published on that date' : 'the first weekly edition is not published yet' }); }
}
// B-16 (2026-09-29): limita de ritm se tine pe clientul numit de gateway (x-aere-client, calculat din Cloudflare si nginx), NU pe
// X-Forwarded-For: primul lui element il alege clientul, deci limita se ocolea schimbandu-l. Serviciul asculta numai pe
// 127.0.0.1, deci antetul il poate pune numai cine ruleaza pe gazda (gateway-ul).
const ip = String(req.headers['x-aere-client'] || req.socket.remoteAddress || '').trim();
let domain = null, fresh = false;
if (req.method === 'POST' && url.pathname === '/scan') {
let b; try { b = JSON.parse((await corp(req)) || 'null'); } catch { return json(res, 400, { error: 'bad_json' }); }
domain = String(b?.domain || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '');
fresh = b?.fresh === true;
} else if (req.method === 'GET' && url.pathname.startsWith('/scan/')) {
domain = decodeURIComponent(url.pathname.slice(6)).trim().toLowerCase();
} else return json(res, 404, { error: 'not_found' });
if (!domeniuValid(domain)) return json(res, 400, { error: 'bad_domain', hint: 'a public hostname, e.g. example.com' });
const c = cache.get(domain);
if (c && !fresh && Date.now() - c.at < TTL_MS) return json(res, 200, { ...c.report, cached: true });
if (req.method === 'GET') return json(res, 404, { error: 'not_scanned_yet', hint: 'POST /scan {"domain": "..."}' });
if (!ritmOk(ip)) return json(res, 429, { error: 'rate_limited', hint: 'at most 12 scans per minute per client' });
let report;
try { report = await cuLimita(() => scaneaza(domain)); } catch (e) { if (e && e.busy) return json(res, 503, { error: 'busy', hint: 'too many scans are waiting; retry in a minute' }); throw e; }
if (!report.error) cache.set(domain, { at: Date.now(), report });
if (cache.size > 5000) { const k = cache.keys().next().value; cache.delete(k); }
return json(res, report.error ? 422 : 200, report);
} catch (e) { return json(res, 500, { error: 'internal', detail: String(e.message || e).slice(0, 80) }); }
}).listen(PORT, '127.0.0.1', () => console.log(`aere-quantum-readiness on 127.0.0.1:${PORT}, OpenSSL ${process.versions.openssl}, group ${PQ_GROUP}`));