#!/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}`));