// Probele gateway-ului PQ (pq-gateway.mjs). Totul pe 127.0.0.1, pe porturi alese de sistem; nicio retea reala. // node aerenew/cloud-gateway/pq-gateway/proba-pq-gateway.mjs // // Ce porneste: un certificat auto-semnat facut cu openssl CLI intr-un dosar temporar, un upstream HTTP local care // ecouieste metoda, calea, antetele si sha256-ul corpului, si gateway-ul ca PROCES COPIL (node pq-gateway.mjs cu // mediul lui), exact cum l-ar rula un client. Singura exceptie e (l), oprirea curata, care cheama functia exportata in // proces: pe Windows un semnal trimis unui copil il omoara fara handler, deci semnalul adevarat nu se poate livra aici. // // Grupul negociat se citeste in trei feluri, fiindca fiecare singur ar fi putut minti: // 1. DE PE FIR: un robinet TCP intre client si gateway retine octetii, iar ServerHello (in clar in TLS 1.3) se // desface pana la extensia key_share (0x0033); 0x11ec = X25519MLKEM768, 0x001d = X25519. // 2. openssl s_client, un client independent de codul nostru si de Node ("Negotiated TLS1.3 group"). // 3. getEphemeralKeyInfo() din clientul Node, TIPARIT exact. Masurat 2026-09-25 pe Node 24.14.1 + OpenSSL 3.5.5: // intoarce {} pentru grupul hibrid (si {type:'ECDH',name:'X25519'} pentru cel clasic), deci API-ul NU numeste // hibridul; proba cere doar ca el sa nu pretinda un grup clasic, iar numele il ia de pe fir. // // Drumul gateway -> upstream https se citeste la fel, cu un al doilea robinet in fata unui upstream https local. // // Iesire: un rand " OK ", " ESEC " sau " -- " (nemasurat) pe proba, apoi "N treceri, M esecuri". Cod 1 la esec, // 3 cand pregatirea a cazut (STRICAT: nicio proba nu a rulat). import http from 'node:http'; import https from 'node:https'; import tls from 'node:tls'; import net from 'node:net'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import readline from 'node:readline'; import { EventEmitter } from 'node:events'; import { spawn, spawnSync, execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const GATEWAY = path.join(AICI, 'pq-gateway.mjs'); const CALE_STARE = '/.well-known/aere-pq-gateway'; const GAZDA = '127.0.0.1'; const HIBRID = 0x11ec, X25519 = 0x001d; const NUME_GRUP = { 0x11ec: 'X25519MLKEM768', 0x11eb: 'SecP256r1MLKEM768', 0x11ed: 'SecP384r1MLKEM1024', 0x001d: 'X25519', 0x001e: 'X448', 0x0017: 'P-256', 0x0018: 'P-384', 0x0019: 'P-521' }; const numeGrup = (g) => (g === null || g === undefined ? 'nimic' : NUME_GRUP[g] || '0x' + g.toString(16).padStart(4, '0')); const HRR = Buffer.from('cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c', 'hex'); const dormi = (ms) => new Promise((r) => setTimeout(r, ms)); let treceri = 0, esecuri = 0, nemasurate = 0; const NEMASURAT = Symbol('nemasurat'); async function test(nume, fn) { try { const r = await fn(); if (r && r[NEMASURAT]) { console.log(` -- ${nume}: NEMASURAT, ${r.motiv}`); nemasurate++; return; } console.log(` OK ${nume}${r ? ` [${r}]` : ''}`); treceri++; } catch (e) { console.log(` ESEC ${nume}: ${String(e && e.message ? e.message : e).replace(/\s+/g, ' ').slice(0, 400)}`); esecuri++; } } function cere(cond, mesaj) { if (!cond) throw new Error(mesaj); } // ---------------------------------------------------------------- pregatirea const copii = []; const deInchis = []; let TMP = null; function curata() { for (const p of copii) { try { p.kill(); } catch { /* deja mort */ } } for (const s of socluriTacute) s.destroy(); for (const s of deInchis) { try { s.close(); } catch { /* deja inchis */ } } if (TMP) { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ramane in temp */ } } } process.on('exit', curata); // Paznic: o proba agatata nu are voie sa tina suita la nesfarsit. Dupa ultimul rand, un rest agatat (un socket // ramas deschis) nu mai schimba verdictul, doar il spune. let suitaTerminata = false; const paznic = setTimeout(() => { if (suitaTerminata) { console.log('(nota: bucla nu s-a golit singura in 240 s; iesire fortata cu verdictul de mai sus)'); process.exit(process.exitCode || 0); } console.log('STRICAT: suita a depasit 240 s'); curata(); process.exit(3); }, 240000); paznic.unref(); function gasesteOpenssl() { const candidati = [process.env.AERE_OPENSSL, 'openssl', 'C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', '/usr/bin/openssl'].filter(Boolean); for (const bin of candidati) { try { const v = execFileSync(bin, ['version'], { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); const m = v.match(/OpenSSL (\d+)\.(\d+)/); if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 5))) return { bin, versiune: v }; } catch { /* urmatorul */ } } return null; } // Robinet TCP: tine octetii fiecarei conexiuni in ambele sensuri, ca grupul sa se citeasca de pe fir. function robinet(portTinta) { const conexiuni = []; const srv = net.createServer((cl) => { const rec = { c2s: [], s2c: [] }; conexiuni.push(rec); const sv = net.connect(portTinta, GAZDA); cl.on('data', (b) => rec.c2s.push(b)); sv.on('data', (b) => rec.s2c.push(b)); cl.pipe(sv); sv.pipe(cl); const gata = () => { cl.destroy(); sv.destroy(); }; cl.on('error', gata); sv.on('error', gata); cl.on('close', gata); sv.on('close', gata); }); deInchis.push(srv); return new Promise((r) => srv.listen(0, GAZDA, () => r({ port: srv.address().port, conexiuni }))); } // Mesajele de handshake in clar (inregistrari de tip 22 dinaintea primei inregistrari criptate, tip 23). function mesajeHandshake(bucati) { const b = Buffer.concat(bucati); const flux = []; let i = 0; while (i + 5 <= b.length) { const tip = b[i], lung = b.readUInt16BE(i + 3); if (i + 5 + lung > b.length || tip === 23) break; if (tip === 22) flux.push(b.subarray(i + 5, i + 5 + lung)); i += 5 + lung; } const h = Buffer.concat(flux); const mesaje = []; let p = 0; while (p + 4 <= h.length) { const lung = h.readUIntBE(p + 1, 3); if (p + 4 + lung > h.length) break; mesaje.push({ tip: h[p], corp: h.subarray(p + 4, p + 4 + lung) }); p += 4 + lung; } return mesaje; } function extensii(c, p, sfarsit) { const m = new Map(); while (p + 4 <= sfarsit) { const t = c.readUInt16BE(p), l = c.readUInt16BE(p + 2); m.set(t, c.subarray(p + 4, p + 4 + l)); p += 4 + l; } return m; } function clientHello(c) { let p = 2 + 32; p += 1 + c[p]; p += 2 + c.readUInt16BE(p); p += 1 + c[p]; const lung = c.readUInt16BE(p); p += 2; const ext = extensii(c, p, p + lung); const grupuri = [], cote = [], versiuni = []; const sg = ext.get(0x000a); if (sg) for (let q = 2; q + 2 <= 2 + sg.readUInt16BE(0); q += 2) grupuri.push(sg.readUInt16BE(q)); const ks = ext.get(0x0033); if (ks) { let q = 2; const sf = 2 + ks.readUInt16BE(0); while (q + 4 <= sf) { cote.push(ks.readUInt16BE(q)); q += 4 + ks.readUInt16BE(q + 2); } } const sv = ext.get(0x002b); if (sv) for (let q = 1; q + 2 <= 1 + sv[0]; q += 2) versiuni.push(sv.readUInt16BE(q)); else versiuni.push(c.readUInt16BE(0)); return { grupuri, cote, versiuni }; } function serverHello(c) { let p = 2; const aleator = c.subarray(p, p + 32); p += 32; p += 1 + c[p]; p += 3; const lung = c.readUInt16BE(p); p += 2; const ext = extensii(c, p, p + lung); const ks = ext.get(0x0033), sv = ext.get(0x002b); return { hrr: aleator.equals(HRR), grup: ks ? ks.readUInt16BE(0) : null, versiune: sv ? sv.readUInt16BE(0) : c.readUInt16BE(0) }; } function citesteFir(rec) { return { ch: mesajeHandshake(rec.c2s).filter((m) => m.tip === 1).map((m) => clientHello(m.corp)), sh: mesajeHandshake(rec.s2c).filter((m) => m.tip === 2).map((m) => serverHello(m.corp)), }; } const descrieSH = (sh) => sh.map((s) => (s.hrr ? `HRR->${numeGrup(s.grup)}` : `SH ${numeGrup(s.grup)}`)).join(', ') || 'niciun ServerHello'; let CERT_PEM, CHEIE_PEM, CALE_CERT, CALE_CHEIE, OPENSSL, AMPRENTA; function cerere({ port, cale = '/', metoda = 'GET', anteturi = {}, corp = null, curbe = 'X25519MLKEM768', maxVersion, timeoutMs = 8000, ca = CERT_PEM }) { return new Promise((resolve, reject) => { let eki = null, protocol = null; const r = https.request({ host: GAZDA, port, path: cale, method: metoda, headers: anteturi, ca, servername: 'localhost', agent: false, ...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}), }, (res) => { const b = []; res.on('data', (x) => b.push(x)); res.on('end', () => resolve({ status: res.statusCode, anteturi: res.headers, corp: Buffer.concat(b), eki, protocol })); res.on('error', reject); }); r.on('socket', (s) => s.once('secureConnect', () => { eki = s.getEphemeralKeyInfo(); protocol = s.getProtocol(); })); r.setTimeout(timeoutMs, () => r.destroy(new Error(`clientul a asteptat ${timeoutMs} ms fara raspuns`))); r.on('error', reject); r.end(corp || undefined); }); } const json = (r) => { try { return JSON.parse(r.corp.toString('utf8')); } catch { throw new Error(`corpul nu e JSON (status ${r.status}): ${r.corp.toString('utf8').slice(0, 120)}`); } }; // Numai strangerea de mana; intoarce ok sau codul erorii din client. function strangere({ port, curbe, maxVersion, alpn }) { return new Promise((resolve) => { const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}), ...(alpn ? { ALPNProtocols: alpn } : {}), }, () => { const r = { ok: true, eki: s.getEphemeralKeyInfo(), protocol: s.getProtocol(), alpn: s.alpnProtocol }; s.end(); resolve(r); }); s.setTimeout(8000, () => s.destroy(new Error('strangerea nu s-a terminat in 8 s'))); s.on('error', (e) => resolve({ ok: false, cod: e.code, mesaj: e.message })); }); } // O cerere de upgrade scrisa de mana; intoarce tot ce a venit pana la inchiderea conexiunii. function upgradeBrut(port, cale, ms = 6000) { return new Promise((resolve, reject) => { const cheie = crypto.randomBytes(16).toString('base64'); const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => { s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`); }); let acc = Buffer.alloc(0); const t = setTimeout(() => { s.destroy(); reject(new Error(`conexiunea nu s-a inchis in ${ms} ms; primit: '${acc.toString('latin1').split('\r\n')[0] || 'nimic'}'`)); }, ms); s.on('data', (b) => { acc = Buffer.concat([acc, b]); }); s.on('error', () => {}); s.on('close', () => { clearTimeout(t); const txt = acc.toString('latin1'); const k = txt.indexOf('\r\n\r\n'); resolve({ antet: k === -1 ? txt : txt.slice(0, k), corp: k === -1 ? '' : txt.slice(k + 4) }); }); }); } async function stare(port, ca) { const r = await cerere({ port, cale: CALE_STARE, ...(ca ? { ca } : {}) }); cere(r.status === 200, `starea a raspuns ${r.status}`); return json(r); } async function asteaptaStare(port, pred, ms = 4000, ca) { const t0 = Date.now(); let s; while (Date.now() - t0 < ms) { s = await stare(port, ca); if (pred(s)) return s; await dormi(100); } return s; } function sClient(argumente, intrare = '', ms = 20000) { return new Promise((resolve) => { const p = spawn(OPENSSL, ['s_client', ...argumente], { stdio: ['pipe', 'pipe', 'pipe'] }); let o = '', e = ''; const t = setTimeout(() => p.kill(), ms); p.stdout.on('data', (b) => { o += b; }); p.stderr.on('data', (b) => { e += b; }); p.on('close', (cod) => { clearTimeout(t); resolve({ cod, o, e }); }); p.on('error', (err) => { clearTimeout(t); resolve({ cod: -1, o, e: e + String(err.message) }); }); p.stdin.on('error', () => {}); p.stdin.end(intrare); }); } function mediuGateway(extra) { const env = { ...process.env }; for (const k of Object.keys(env)) if (k.startsWith('AERE_PQGW_')) delete env[k]; return { ...env, ...extra }; } function pornesteGw(nume, extra) { return new Promise((resolve, reject) => { const p = spawn(process.execPath, [GATEWAY], { env: mediuGateway(extra), stdio: ['ignore', 'pipe', 'pipe'] }); copii.push(p); let err = ''; const jurnal = []; p.stderr.on('data', (b) => { err += b; }); const t = setTimeout(() => reject(new Error(`${nume}: nu a raportat 'listening' in 10 s; stderr: ${err.slice(0, 300)}`)), 10000); readline.createInterface({ input: p.stdout }).on('line', (l) => { let j = null; try { j = JSON.parse(l); } catch { return; } jurnal.push(j); if (j.event === 'listening') { clearTimeout(t); resolve({ p, port: j.port, nume, jurnal, pornire: j }); } }); p.on('exit', (cod) => { clearTimeout(t); reject(new Error(`${nume} a iesit cu ${cod}: ${err.slice(0, 300)}`)); }); }); } // Upstream-ul de proba: ecou pentru orice cerere, 101 + salut + ecou pentru upgrade (sau 403 pe /ws-refuz). const jurnalUpstream = []; const evUp = new EventEmitter(); // Raspunde INAINTE sa fi primit tot corpul, apoi curge inca ~2,8 s dupa sfarsitul cererii (ca un flux SSE). function devreme(req, res) { res.writeHead(200, { 'content-type': 'text/plain' }); res.write('inceput\n'); req.resume(); req.on('end', () => { let n = 0; const t = setInterval(() => { if (res.destroyed) { clearInterval(t); return; } n++; if (n <= 6) res.write(`bucata ${n}\n`); else { clearInterval(t); res.end('gata\n'); } }, 400); }); } function ecou(req, res) { if (req.url === '/devreme') return devreme(req, res); const h = crypto.createHash('sha256'); let n = 0; const id = req.headers['x-proba-id']; req.on('data', (b) => { if (n === 0 && id) evUp.emit(`primul:${id}`); n += b.length; h.update(b); }); req.on('end', async () => { const u = new URL(req.url, 'http://upstream.invalid'); jurnalUpstream.push({ method: req.method, url: req.url }); if (u.pathname === '/slow') await dormi(Number(u.searchParams.get('ms')) || 1000); if (res.destroyed) return; const corp = JSON.stringify({ method: req.method, url: req.url, rawHeaders: req.rawHeaders, headers: req.headers, bodySha256: h.digest('hex'), bodyBytes: n }); res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp) }); res.end(corp); }); } function laUpgrade(req, sock, head) { jurnalUpstream.push({ method: req.method, url: req.url, upgrade: req.headers.upgrade, headers: req.headers, tls: Boolean(sock.encrypted) }); sock.on('error', () => {}); if (req.url === '/ws-refuz') { sock.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: close\r\n\r\nnu-ws'); return; } if (req.url === '/ws-lent') { setTimeout(() => sock.destroy(), 2500); return; } const accept = crypto.createHash('sha1').update(String(req.headers['sec-websocket-key']) + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); sock.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); sock.write('SALUT-DE-LA-UPSTREAM\n'); if (head && head.length) sock.write(head); sock.on('data', (d) => sock.write(d)); } function pornesteUpstreamHttps() { const srv = https.createServer({ key: CHEIE_PEM, cert: CERT_PEM }, ecou); srv.on('upgrade', laUpgrade); deInchis.push(srv); return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port))); } // Accepta TCP si nu spune nimic: o strangere TLS catre el nu se termina niciodata. const socluriTacute = new Set(); function pornesteTacut() { const srv = net.createServer((s) => { socluriTacute.add(s); s.on('error', () => {}); s.on('close', () => socluriTacute.delete(s)); }); deInchis.push(srv); return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port))); } function pornesteUpstream() { const srv = http.createServer(ecou); srv.on('upgrade', laUpgrade); deInchis.push(srv); return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port))); } async function portLiber() { const s = net.createServer(); await new Promise((r) => s.listen(0, GAZDA, r)); const p = s.address().port; await new Promise((r) => s.close(r)); return p; } let GH, GP, GM, GS, GSX, GT, TH, TP, TS, PORT_UP; try { const o = gasesteOpenssl(); if (!o) throw new Error('nu am gasit openssl >= 3.5 (AERE_OPENSSL, PATH sau Git for Windows)'); OPENSSL = o.bin; TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pqgw-proba-')); CALE_CERT = path.join(TMP, 'cert.pem'); CALE_CHEIE = path.join(TMP, 'cheie.pem'); // execFileSync cu cai native (Node pe Windows da cai Windows), deci nicio conversie MSYS pe drum. execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', CALE_CHEIE, '-out', CALE_CERT, '-days', '2', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'], { stdio: ['ignore', 'pipe', 'pipe'] }); CERT_PEM = fs.readFileSync(CALE_CERT); CHEIE_PEM = fs.readFileSync(CALE_CHEIE, 'utf8'); AMPRENTA = new crypto.X509Certificate(CERT_PEM).fingerprint256; PORT_UP = await pornesteUpstream(); const portUpHttps = await pornesteUpstreamHttps(); const portTacut = await pornesteTacut(); TS = await robinet(portUpHttps); const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only' }; [GH, GP, GM, GS, GSX, GT] = await Promise.all([ pornesteGw('gateway hybrid-only', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUEST_TIMEOUT_MS: '1500' }), pornesteGw('gateway hybrid-preferred', { ...baza, AERE_PQGW_MODE: 'hybrid-preferred', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` }), pornesteGw('gateway catre upstream oprit', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${await portLiber()}` }), pornesteGw('gateway catre upstream https (cu CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${TS.port}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT }), pornesteGw('gateway catre upstream https (fara CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portUpHttps}` }), pornesteGw('gateway catre upstream tacut', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portTacut}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT, AERE_PQGW_CONNECT_TIMEOUT_MS: '1000' }), ]); [TH, TP] = await Promise.all([robinet(GH.port), robinet(GP.port)]); console.log(`pregatire: ${o.versiune}; Node ${process.version} + OpenSSL ${process.versions.openssl}; certificat ${AMPRENTA.slice(0, 23)}...`); } catch (e) { console.log(`STRICAT: pregatirea a cazut, nicio proba nu a rulat: ${e.message}`); process.exitCode = 3; curata(); process.exit(3); } // ---------------------------------------------------------------- pozitive await test('(a) hybrid-only + client Node X25519MLKEM768: cererea trece, grupul de pe fir e X25519MLKEM768', async () => { const inainte = TH.conexiuni.length; const r = await cerere({ port: TH.port, cale: '/a?x=1', curbe: 'X25519MLKEM768' }); cere(r.status === 200, `status ${r.status}`); cere(json(r).url === '/a?x=1', `upstream-ul a vazut ${json(r).url}`); cere(r.protocol === 'TLSv1.3', `protocol ${r.protocol}`); const rec = TH.conexiuni[inainte]; cere(rec, 'robinetul nu a vazut conexiunea'); const f = citesteFir(rec); cere(f.ch.length >= 1 && f.ch[0].cote.includes(HIBRID), `ClientHello nu poarta o cota X25519MLKEM768 (cote: ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'})`); const final = f.sh.filter((s) => !s.hrr).pop(); cere(final && final.grup === HIBRID, `ServerHello de pe fir: ${descrieSH(f.sh)}`); const ekiText = JSON.stringify(r.eki); cere(!r.eki || !r.eki.name || /MLKEM/i.test(r.eki.name), `getEphemeralKeyInfo pretinde un grup clasic: ${ekiText}`); return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${ekiText}${r.eki && r.eki.name ? '' : ' (Node nu numeste grupul hibrid)'}`; }); await test('(b) openssl s_client -groups X25519MLKEM768 (client independent): strangere reusita, grupul negociat X25519MLKEM768', async () => { const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519MLKEM768', '-servername', 'localhost', '-CAfile', CALE_CERT, '-verify_return_error', '-ign_eof'], 'GET /b-openssl HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim(); cere(r.cod === 0, `s_client a iesit cu ${r.cod}: ${r.e.slice(0, 200)}`); cere(rand.includes('X25519MLKEM768'), `randul grupului: '${rand || 'lipseste'}'`); cere(/Verify return code: 0 \(ok\)/.test(r.o), 'certificatul nu a fost verificat de s_client'); cere(/HTTP\/1\.1 200/.test(r.o) && r.o.includes('"url":"/b-openssl"'), 'raspunsul HTTP prin s_client lipseste sau nu vine de la upstream'); return rand; }); await test('(c) POST de 1 MiB ajunge intreg la upstream (sha256 egal)', async () => { const corp = crypto.randomBytes(1024 * 1024); const r = await cerere({ port: GH.port, cale: '/c', metoda: 'POST', corp, anteturi: { 'content-type': 'application/octet-stream', 'content-length': corp.length } }); cere(r.status === 200, `status ${r.status}`); const j = json(r); const asteptat = crypto.createHash('sha256').update(corp).digest('hex'); cere(j.bodyBytes === corp.length, `upstream-ul a primit ${j.bodyBytes} octeti din ${corp.length}`); cere(j.bodySha256 === asteptat, 'sha256 diferit la upstream'); return `${j.bodyBytes} octeti, sha256 ${asteptat.slice(0, 16)}...`; }); await test('(k) corpul curge catre upstream fara bufferare intreaga (chunked, cererea ramane deschisa)', async () => { const id = `curge-${crypto.randomUUID()}`; const primul = new Promise((r) => evUp.once(`primul:${id}`, () => r(true))); const p1 = crypto.randomBytes(64 * 1024), p2 = crypto.randomBytes(64 * 1024); let aVazut = null; const r = await new Promise((resolve, reject) => { const q = https.request({ host: GAZDA, port: GH.port, path: '/curge', method: 'POST', headers: { 'x-proba-id': id, 'content-type': 'application/octet-stream' }, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => { const b = []; res.on('data', (x) => b.push(x)); res.on('end', () => resolve({ status: res.statusCode, corp: Buffer.concat(b) })); }); q.setTimeout(15000, () => q.destroy(new Error('fara raspuns in 15 s'))); q.on('error', reject); q.write(p1); Promise.race([primul, dormi(5000).then(() => false)]).then((v) => { aVazut = v; q.end(p2); }); }); cere(aVazut === true, 'upstream-ul nu a primit NIMIC din corp cat timp clientul tinea cererea deschisa (5 s): corpul e bufferat'); cere(r.status === 200, `status ${r.status}`); const j = json(r); cere(j.bodySha256 === crypto.createHash('sha256').update(Buffer.concat([p1, p2])).digest('hex') && j.bodyBytes === p1.length + p2.length, `corp diferit la upstream (${j.bodyBytes} octeti)`); cere(!j.headers['transfer-encoding'] || j.headers['transfer-encoding'] === 'chunked', `transfer-encoding la upstream: ${j.headers['transfer-encoding']}`); return `primii octeti la upstream inainte de sfarsitul cererii; ${j.bodyBytes} octeti intregi`; }); // Un tunel WebSocket prin gateway-ul de pe `port`: 101, salutul upstream-ului, apoi ecoul unei incarcaturi aleatoare. async function verificaTunel(port, cale) { const cheie = crypto.randomBytes(16).toString('base64'); const asteptatAccept = crypto.createHash('sha1').update(cheie + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); const incarcatura = crypto.randomBytes(32 * 1024); const salut = 'SALUT-DE-LA-UPSTREAM\n'; const rez = await new Promise((resolve, reject) => { const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => { s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`); }); let acc = Buffer.alloc(0), antet = null, trimis = false; const t = setTimeout(() => { s.destroy(); reject(new Error(`tunelul nu a intors tot in 8 s (antet: ${antet ? antet.split('\r\n')[0] : 'niciunul'}, octeti dupa antet: ${antet ? acc.length : 0})`)); }, 8000); s.on('data', (b) => { acc = Buffer.concat([acc, b]); if (antet === null) { const k = acc.indexOf('\r\n\r\n'); if (k === -1) return; antet = acc.subarray(0, k).toString('latin1'); acc = acc.subarray(k + 4); } if (!trimis && acc.length >= salut.length) { trimis = true; s.write(incarcatura); } if (acc.length >= salut.length + incarcatura.length) { clearTimeout(t); s.end(); resolve({ antet, acc }); } }); s.on('error', (e) => { clearTimeout(t); reject(e); }); }); cere(/^HTTP\/1\.1 101/.test(rez.antet), `raspuns: ${rez.antet.split('\r\n')[0]}`); cere(rez.antet.toLowerCase().includes(`sec-websocket-accept: ${asteptatAccept.toLowerCase()}`), 'Sec-WebSocket-Accept lipsa sau gresit'); cere(rez.acc.subarray(0, salut.length).toString() === salut, 'salutul upstream -> client nu a trecut'); cere(rez.acc.subarray(salut.length, salut.length + incarcatura.length).equals(incarcatura), 'ecoul client -> upstream -> client difera'); const u = jurnalUpstream.filter((x) => x.upgrade && x.url === cale).pop(); cere(u && u.upgrade === 'websocket' && u.headers['x-aere-pq-gateway'] === 'hybrid-only' && u.headers['x-forwarded-proto'] === 'https', 'upstream-ul nu a vazut cererea de upgrade cu antetele gateway-ului'); return { octeti: incarcatura.length, u }; } await test('(k2) raspuns inceput inainte de sfarsitul cererii si care curge mai mult decat REQUEST_TIMEOUT: nu e taiat', async () => { const t0 = Date.now(); let msLaSfarsitCerere = null; const r = await new Promise((resolve, reject) => { const q = https.request({ host: GAZDA, port: GH.port, path: '/devreme', method: 'POST', headers: { 'content-type': 'application/octet-stream' }, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => { let corp = ''; res.setEncoding('utf8'); res.on('data', (x) => { corp += x; }); res.on('end', () => resolve({ status: res.statusCode, corp, complet: res.complete })); res.on('close', () => { if (!res.complete) resolve({ status: res.statusCode, corp, complet: false }); }); res.on('error', () => {}); // cererea se incheie abia DUPA ce raspunsul a inceput msLaSfarsitCerere = Date.now() - t0; q.end('sfarsitul-cererii'); }); q.setTimeout(10000, () => q.destroy(new Error('fara raspuns complet in 10 s'))); q.on('error', reject); q.write('inceputul-cererii'); }); const ms = Date.now() - t0; cere(r.status === 200, `status ${r.status}`); cere(r.complet && r.corp.endsWith('gata\n') && (r.corp.match(/bucata/g) || []).length === 6, `raspuns taiat la ${ms} ms (${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii): ${JSON.stringify(r.corp.slice(-30))}`); cere(ms - msLaSfarsitCerere > 2000, `fixtura: raspunsul a curs doar ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii, sub REQUEST_TIMEOUT+marja, deci nu masoara nimic`); return `raspuns complet, ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii (REQUEST_TIMEOUT 1500)`; }); await test('(d) upgrade WebSocket: 101, salutul upstream-ului si ecoul trec prin tunel in ambele sensuri', async () => { const r = await verificaTunel(GH.port, '/ws'); cere(r.u.tls === false, 'fixtura: upstream-ul acestui gateway trebuia sa fie http'); return `${r.octeti} octeti ecou identici, salut primit`; }); await test('(d3) upgrade WebSocket catre upstream https (UPSTREAM_CA): tunelul merge, iar drumul catre upstream prefera hibridul', async () => { const inainte = TS.conexiuni.length; const r = await verificaTunel(GS.port, '/ws-tls'); cere(r.u.tls === true, 'upstream-ul nu a primit upgrade-ul peste TLS'); const rec = TS.conexiuni.slice(inainte).find((x) => citesteFir(x).sh.length > 0); cere(rec, 'robinetul din fata upstream-ului https nu a vazut strangerea de mana a tunelului'); const f = citesteFir(rec); const final = f.sh.filter((s) => !s.hrr).pop(); cere(final && final.grup === HIBRID, `drumul tunelului catre upstream: ${descrieSH(f.sh)}`); return `${r.octeti} octeti ecou identici peste TLS; drum catre upstream: ${descrieSH(f.sh)}`; }); await test('(d2) upstream-ul refuza upgrade-ul: raspunsul lui (403) ajunge la client cu Connection: close, apoi conexiunea se inchide', async () => { const r = await upgradeBrut(GH.port, '/ws-refuz'); cere(/^HTTP\/1\.1 403/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`); cere(/\r\nconnection: close(\r\n|$)/i.test(r.antet), `fara Connection: close in raspuns (${r.antet.replace(/\r\n/g, ' | ').slice(0, 200)})`); cere(r.corp === 'nu-ws', `corp '${r.corp.slice(0, 40)}'`); return '403 + corpul upstream-ului, conexiune inchisa'; }); await test('(e) antetele: X-Forwarded-* puse de gateway, x-aere-pq-gateway prezent, hop-by-hop si numele din Connection NU ajung', async () => { const r = await cerere({ port: GH.port, cale: '/e', anteturi: { Connection: 'keep-alive, X-Secret-Hop', 'X-Secret-Hop': 'nu-trebuie-sa-ajunga', 'Keep-Alive': 'timeout=5', 'Proxy-Authorization': 'Basic bnU=', TE: 'trailers', 'X-Forwarded-For': '203.0.113.9', 'x-aere-pq-gateway': 'falsificat', 'X-End-To-End': 'da', Host: 'aplicatia.example', } }); cere(r.status === 200, `status ${r.status}`); const h = json(r).headers; // controlul pozitiv al fixturii: un antet obisnuit TREBUIE sa treaca, altfel lipsa celorlalte nu inseamna nimic cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic'); cere(h['x-forwarded-proto'] === 'https', `X-Forwarded-Proto: ${h['x-forwarded-proto']}`); cere(h['x-aere-pq-gateway'] === 'hybrid-only', `x-aere-pq-gateway: ${h['x-aere-pq-gateway']}`); cere(h['x-forwarded-host'] === 'aplicatia.example', `X-Forwarded-Host: ${h['x-forwarded-host']}`); cere(h['x-forwarded-for'] === '127.0.0.1', `X-Forwarded-For: ${h['x-forwarded-for']} (valoarea clientului nu are voie sa treaca)`); const scapate = ['x-secret-hop', 'keep-alive', 'proxy-authorization', 'te'].filter((n) => n in h); cere(scapate.length === 0, `au ajuns la upstream: ${scapate.join(', ')}`); cere(!String(h.connection || '').toLowerCase().includes('x-secret-hop'), `Connection la upstream: ${h.connection}`); return 'x-secret-hop, keep-alive, proxy-authorization, te oprite; x-end-to-end trecut'; }); await test('(e2) X-Forwarded-For pe un ascultator dual-stack: ::ffff:a.b.c.d devine a.b.c.d, restul ramane neatins (functia, fara socket)', async () => { // Masurat pe functie, nu pe un socket: un ascultator dual-stack ar insemna sa ascultam pe toate interfetele. const mod = await import(pathToFileURL(GATEWAY).href); const cazuri = [['::ffff:127.0.0.1', '127.0.0.1'], ['::FFFF:198.51.100.7', '198.51.100.7'], ['::1', '::1'], ['2001:db8::5', '2001:db8::5'], ['127.0.0.1', '127.0.0.1'], ['::ffff:nu-e-ip', '::ffff:nu-e-ip'], [undefined, '']]; const rele = cazuri.filter(([i, o]) => mod.adresaClient(i) !== o).map(([i, o]) => `${i} -> ${mod.adresaClient(i)} (asteptat ${o})`); cere(rele.length === 0, rele.join('; ')); return `${cazuri.length} cazuri`; }); await test('(f) punctul de stare: mode, contoare, amprenta certificatului, FARA cheia privata, servit de gateway (nu de upstream)', async () => { const inainte = jurnalUpstream.length; const r = await cerere({ port: GH.port, cale: CALE_STARE }); cere(r.status === 200, `status ${r.status}`); cere(String(r.anteturi['content-type']).startsWith('application/json'), `content-type ${r.anteturi['content-type']}`); const text = r.corp.toString('utf8'); const s = JSON.parse(text); cere(s.mode === 'hybrid-only', `mode ${s.mode}`); cere(JSON.stringify(s.groupsOffered) === '["X25519MLKEM768"]', `groupsOffered ${JSON.stringify(s.groupsOffered)}`); cere(s.minTlsVersion === 'TLSv1.3', `minTlsVersion ${s.minTlsVersion}`); cere(s.guarantee && s.guarantee.hybridKeyExchangeOnEveryConnection === true, 'hybrid-only nu isi declara garantia structurala'); cere(s.certificate && s.certificate.sha256 === AMPRENTA, `amprenta ${s.certificate && s.certificate.sha256} != ${AMPRENTA}`); const k = s.counters || {}; for (const n of ['connectionsAccepted', 'requestsForwarded', 'responses502', 'responses504']) cere(Number.isInteger(k[n]), `contorul ${n} lipseste`); cere(k.handshakesRefused && ['no shared group', 'unsupported protocol', 'other'].every((m) => Number.isInteger(k.handshakesRefused[m])), 'contoarele de refuz pe motiv lipsesc'); cere(k.connectionsAccepted >= 1 && k.requestsForwarded >= 1, 'contoarele nu numara nimic dupa probele de dinainte'); const baza64 = CHEIE_PEM.split(/\r?\n/).filter((l) => l && !l.startsWith('-----')).join(''); const fragmente = [baza64.slice(8, 48), baza64.slice(-40, -4), 'PRIVATE KEY', CALE_CHEIE, path.basename(CALE_CHEIE)]; const gasite = fragmente.filter((x) => x && text.includes(x)); cere(gasite.length === 0, `starea contine material sau cale a cheii (${gasite.length} fragmente)`); cere(jurnalUpstream.slice(inainte).every((x) => !x.url.startsWith(CALE_STARE)), 'cererea de stare a fost trimisa la upstream'); return `acceptate ${k.connectionsAccepted}, trimise ${k.requestsForwarded}; ${fragmente.length} fragmente ale cheii cautate, 0 gasite`; }); // ---------------------------------------------------------------- controale negative, fiecare cu MOTIVUL await test('(g) hybrid-only + client numai X25519: refuzat la strangerea de mana, motivul numarat e "no shared group"', async () => { const s0 = await stare(GH.port); const inainte = TH.conexiuni.length; const r = await strangere({ port: TH.port, curbe: 'X25519' }); const f = citesteFir(TH.conexiuni[inainte] || { c2s: [], s2c: [] }); // fixtura trebuie sa poata EXPRIMA atacul: clientul chiar nu a oferit hibridul cere(f.ch.length === 1 && f.ch[0].grupuri.length === 1 && f.ch[0].grupuri[0] === X25519, `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}, nu numai X25519`); cere(!r.ok, `strangerea de mana a REUSIT fara hibrid (${descrieSH(f.sh)}, getEphemeralKeyInfo ${JSON.stringify(r.eki)})`); cere(f.sh.length === 0, `serverul a trimis ${descrieSH(f.sh)}`); cere(r.cod === 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE', `clientul a primit alt refuz: ${r.cod}`); const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']); const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m]; cere(d('no shared group') === 1, `contorul 'no shared group' a crescut cu ${d('no shared group')} (unsupported protocol +${d('unsupported protocol')}, other +${d('other')})`); cere(d('unsupported protocol') === 0 && d('other') === 0, `au crescut si alte motive: unsupported protocol +${d('unsupported protocol')}, other +${d('other')}`); return `client: ${r.cod}; server: 'no shared group' +1 (${JSON.stringify(s1.counters.handshakesRefusedByCode)})`; }); await test('(g2) hybrid-only + openssl s_client -groups X25519 (client independent): refuzat cu alerta handshake failure', async () => { const s0 = await stare(GH.port); const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519', '-servername', 'localhost', '-CAfile', CALE_CERT], ''); const tot = r.o + r.e; // Masurat 2026-09-25: la refuz s_client tipareste totusi randul grupului, cu '', si 'Cipher is (NONE)'. const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim(); cere(r.cod !== 0, 's_client a iesit cu 0: strangerea de mana a reusit fara hibrid'); cere(/alert handshake failure/i.test(tot) && /SSL alert number 40/.test(tot), `s_client nu a raportat alerta handshake failure (40): ${tot.replace(/\s+/g, ' ').slice(0, 200)}`); cere(rand === '' || /$/.test(rand), `s_client raporteaza un grup negociat: '${rand}'`); cere(/Cipher is \(NONE\)/.test(r.o), 's_client raporteaza o suita negociata'); const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']); cere(s1.counters.handshakesRefused['no shared group'] === s0.counters.handshakesRefused['no shared group'] + 1, "contorul 'no shared group' nu a crescut cu 1"); return (tot.match(/[^\n]*alert handshake failure[^\n]*/i) || [''])[0].trim().slice(0, 120); }); await test('(h) client TLS 1.2 (maxVersion TLSv1.2): refuzat in AMBELE moduri, motivul numarat e "unsupported protocol"', async () => { // Amandoua gateway-urile se masoara inainte de verdict, ca un esec pe primul sa nu ascunda ce spune al doilea. const note = [], probleme = []; for (const [g, t] of [[GH, TH], [GP, TP]]) { try { const s0 = await stare(g.port); const inainte = t.conexiuni.length; const r = await strangere({ port: t.port, maxVersion: 'TLSv1.2' }); const f = citesteFir(t.conexiuni[inainte] || { c2s: [], s2c: [] }); cere(f.ch.length === 1 && !f.ch[0].versiuni.includes(0x0304), `clientul de proba a oferit TLS 1.3 (${f.ch[0] ? f.ch[0].versiuni.map((v) => v.toString(16)) : '-'})`); cere(!r.ok, `un client TLS 1.2 a fost ACCEPTAT (${r.protocol})`); const s1 = await asteaptaStare(g.port, (s) => s.counters.handshakesRefused['unsupported protocol'] > s0.counters.handshakesRefused['unsupported protocol'], 2000); const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m]; cere(r.cod === 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', `clientul a primit alt refuz: ${r.cod} (server: 'no shared cipher' +${d('no shared cipher')}, other +${d('other')})`); cere(d('unsupported protocol') === 1, `'unsupported protocol' +${d('unsupported protocol')}, 'no shared cipher' +${d('no shared cipher')}, other +${d('other')}`); note.push(`${s1.mode}: ${r.cod}, 'unsupported protocol' +1`); } catch (e) { probleme.push(`${g.nume}: ${e.message}`); } } cere(probleme.length === 0, probleme.join(' | ')); return note.join('; '); }); await test('(i) upstream oprit: 502 cu corp JSON, in cateva secunde, numarat', async () => { const s0 = await stare(GM.port); const t0 = Date.now(); const r = await cerere({ port: GM.port, cale: '/i', timeoutMs: 9000 }); const ms = Date.now() - t0; cere(r.status === 502, `status ${r.status}`); const j = json(r); cere(j.error === 'bad_gateway', `corp ${JSON.stringify(j)}`); cere(ms < 7000, `a durat ${ms} ms`); const s1 = await stare(GM.port); cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`); return `502 in ${ms} ms: ${JSON.stringify(j)}`; }); await test('(i2) upstream care nu raspunde la timp: 504 cu corp JSON dupa REQUEST_TIMEOUT, numarat', async () => { const s0 = await stare(GH.port); const t0 = Date.now(); const r = await cerere({ port: GH.port, cale: '/slow?ms=6000', timeoutMs: 9000 }); const ms = Date.now() - t0; cere(r.status === 504, `status ${r.status} dupa ${ms} ms`); const j = json(r); cere(j.error === 'gateway_timeout', `corp ${JSON.stringify(j)}`); cere(ms >= 1400 && ms < 4500, `a durat ${ms} ms (timeout configurat 1500)`); const s1 = await stare(GH.port); cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`); return `504 in ${ms} ms`; }); await test('(i3) upgrade catre upstream oprit: 502 cu corp JSON pe socket, conexiune inchisa, numarat', async () => { const s0 = await stare(GM.port); const r = await upgradeBrut(GM.port, '/ws'); cere(/^HTTP\/1\.1 502/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`); let j; try { j = JSON.parse(r.corp); } catch { throw new Error(`corpul nu e JSON: '${r.corp.slice(0, 80)}'`); } cere(j.error === 'bad_gateway', `corp ${r.corp.trim()}`); const s1 = await stare(GM.port); cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`); return r.corp.trim(); }); await test('(s) clientul pleaca inainte de raspuns (cerere si upgrade): nu se numara ca 502/504 si nu apare ca eroare de upstream', async () => { const s0 = await stare(GP.port); const jurnal0 = GP.jurnal.length; const plecat = await new Promise((resolve) => { const q = https.request({ host: GAZDA, port: GP.port, path: '/slow?ms=2500', ca: CERT_PEM, servername: 'localhost', agent: false }); q.on('error', () => {}); q.end(); setTimeout(() => { q.destroy(); resolve(true); }, 400); }); const sus = await new Promise((resolve) => { const s = tls.connect({ host: GAZDA, port: GP.port, ca: CERT_PEM, servername: 'localhost' }, () => { s.write('GET /ws-lent HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGVzdGVzdGVzdGVzdGVzdA==\r\nSec-WebSocket-Version: 13\r\n\r\n'); setTimeout(() => { s.destroy(); resolve(true); }, 400); }); s.on('error', () => {}); }); cere(plecat && sus, 'fixtura nu a putut pleca'); await dormi(700); const s1 = await stare(GP.port); const d502 = s1.counters.responses502 - s0.counters.responses502, d504 = s1.counters.responses504 - s0.counters.responses504; const erori = GP.jurnal.slice(jurnal0).filter((j) => j.event === 'upstream_error'); cere(s1.counters.requestsForwarded >= s0.counters.requestsForwarded + 2, `fixtura: cererile nu au ajuns la upstream (trimise +${s1.counters.requestsForwarded - s0.counters.requestsForwarded})`); cere(d502 === 0 && d504 === 0, `plecarea clientului numarata ca eroare de upstream: responses502 +${d502}, responses504 +${d504}`); cere(erori.length === 0, `jurnalul gateway-ului scrie upstream_error pentru o plecare a clientului: ${JSON.stringify(erori.map((j) => j.code))}`); return 'responses502 +0, responses504 +0, niciun upstream_error'; }); await test('(q) upstream https cu AERE_PQGW_UPSTREAM_CA: cererea trece, iar drumul gateway -> upstream prefera hibridul (citit de pe fir)', async () => { const inainte = TS.conexiuni.length; const r = await cerere({ port: GS.port, cale: '/q' }); cere(r.status === 200 && json(r).url === '/q', `status ${r.status}`); cere(json(r).headers['x-aere-pq-gateway'] === 'hybrid-only', 'upstream-ul https nu a vazut antetul gateway-ului'); const rec = TS.conexiuni[inainte]; cere(rec, 'robinetul din fata upstream-ului https nu a vazut conexiunea'); const f = citesteFir(rec); const final = f.sh.filter((s) => !s.hrr).pop(); cere(final && final.grup === HIBRID, `drumul catre upstream: ${descrieSH(f.sh)}`); const s = await stare(GS.port); cere(s.upstream && s.upstream.scheme === 'https' && s.upstream.encrypted === true, `starea: ${JSON.stringify(s.upstream)}`); cere(JSON.stringify(Object.keys(s.upstream)) === '["scheme","encrypted"]', `starea spune despre upstream mai mult decat schema: ${JSON.stringify(s.upstream)}`); return `drum catre upstream: ${descrieSH(f.sh)}`; }); await test('(q2) upstream https cu certificat pe care gateway-ul nu il poate verifica (fara UPSTREAM_CA): 502, nicio incredere oarba', async () => { const inainte = jurnalUpstream.length; const r = await cerere({ port: GSX.port, cale: '/q2' }); cere(r.status === 502, `status ${r.status}: gateway-ul a trimis cererea unui upstream neverificat`); cere(json(r).error === 'bad_gateway', `corp ${r.corp.toString().trim()}`); cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/q2'), 'cererea a ajuns la upstream'); const t0 = Date.now(); let rand; while (!(rand = GSX.jurnal.filter((j) => j.event === 'upstream_error').pop()) && Date.now() - t0 < 2000) await dormi(50); cere(rand && /CERT|SELF_SIGNED|VERIFY/i.test(String(rand.code)), `motivul din jurnalul gateway-ului: ${rand ? rand.code : 'lipseste'}`); return `502, motiv in jurnal: ${rand.code}`; }); await test('(r) upstream care accepta TCP dar nu termina strangerea TLS: 504 "upstream connect timeout" dupa CONNECT_TIMEOUT, numarat', async () => { const s0 = await stare(GT.port); const t0 = Date.now(); const r = await cerere({ port: GT.port, cale: '/r', timeoutMs: 9000 }); const ms = Date.now() - t0; cere(r.status === 504, `status ${r.status} dupa ${ms} ms`); const j = json(r); cere(j.detail === 'upstream connect timeout', `corp ${JSON.stringify(j)}`); cere(ms >= 900 && ms < 3500, `a durat ${ms} ms (timeout de conectare configurat 1000)`); const s1 = await stare(GT.port); cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`); return `504 in ${ms} ms: ${JSON.stringify(j)}`; }); await test('(j) hybrid-preferred + client numai X25519: ACCEPTAT pe X25519, iar starea NU pretinde hibrid', async () => { const s0 = await stare(GP.port); const inainte = TP.conexiuni.length; const r = await cerere({ port: TP.port, cale: '/j', curbe: 'X25519' }); cere(r.status === 200, `status ${r.status}`); const f = citesteFir(TP.conexiuni[inainte]); cere(f.ch[0] && f.ch[0].grupuri.join() === String(X25519), `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`); const final = f.sh.filter((s) => !s.hrr).pop(); cere(final && final.grup === X25519, `fir: ${descrieSH(f.sh)}`); cere(r.eki && r.eki.name === 'X25519', `getEphemeralKeyInfo ${JSON.stringify(r.eki)}`); const s1 = await stare(GP.port); cere(s1.mode === 'hybrid-preferred', `mode ${s1.mode}`); cere(s1.guarantee && s1.guarantee.hybridKeyExchangeOnEveryConnection === false, 'hybrid-preferred isi declara hibridul garantat pe fiecare conexiune'); cere(typeof s1.negotiatedGroupPerConnection === 'string' && /not reported/i.test(s1.negotiatedGroupPerConnection), 'starea nu spune ca grupul per conexiune nu e raportat'); const pretentii = []; (function umbla(o, cale) { for (const [k, v] of Object.entries(o || {})) { if (v && typeof v === 'object') umbla(v, `${cale}.${k}`); else if (/hybrid|mlkem|quantum|pq/i.test(k) && (v === true || (typeof v === 'number' && v > 0))) pretentii.push(`${cale}.${k}=${v}`); } })(s1, ''); cere(pretentii.length === 0, `starea pretinde hibrid: ${pretentii.join(', ')}`); cere(s1.counters.connectionsAccepted > s0.counters.connectionsAccepted, 'conexiunea clasica nu a fost numarata ca acceptata'); return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${JSON.stringify(r.eki)}; nicio pretentie de hibrid in stare`; }); await test('(j2) hybrid-preferred prefera CU ADEVARAT: client "X25519:X25519MLKEM768" (cota numai X25519) ajunge pe hibrid prin HelloRetryRequest', async () => { const inainte = TP.conexiuni.length; const r = await cerere({ port: TP.port, cale: '/j2', curbe: 'X25519:X25519MLKEM768' }); cere(r.status === 200, `status ${r.status}`); const f = citesteFir(TP.conexiuni[inainte]); cere(f.ch[0] && f.ch[0].cote.join() === String(X25519) && f.ch[0].grupuri.includes(HIBRID), `fixtura: primul ClientHello are cotele ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'} si grupurile ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`); const final = f.sh.filter((s) => !s.hrr).pop(); cere(final && final.grup === HIBRID, `fir: ${descrieSH(f.sh)} (serverul nu a cerut hibridul)`); return `fir: ${descrieSH(f.sh)}`; }); await test('(m) antet peste limita: 431, nimic trimis la upstream', async () => { const inainte = jurnalUpstream.length; let r; try { r = await cerere({ port: GH.port, cale: '/m-mare', anteturi: { 'x-mare': 'a'.repeat(20000) } }); } catch (e) { throw new Error(`fara raspuns HTTP: ${e.message}`); } cere(r.status === 431, `status ${r.status}`); cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/m-mare'), 'cererea a ajuns la upstream'); return '431'; }); await test('(n) ALPN: un client care ofera h2 primeste http/1.1 (gateway-ul nu face HTTP/2)', async () => { const r = await strangere({ port: GH.port, curbe: 'X25519MLKEM768', alpn: ['h2', 'http/1.1'] }); cere(r.ok, `strangere esuata: ${r.cod}`); cere(r.alpn === 'http/1.1', `ALPN ${r.alpn}`); return `ALPN ${r.alpn}`; }); await test('(o) configuratie din fisier JSON (AERE_PQGW_CONFIG), iar mediul are prioritate', async () => { const fis = path.join(TMP, 'config.json'); fs.writeFileSync(fis, JSON.stringify({ listen: `${GAZDA}:0`, cert: CALE_CERT, key: CALE_CHEIE, upstream: `http://${GAZDA}:${PORT_UP}`, mode: 'hybrid-preferred', connectTimeoutMs: 3000 })); const g = await pornesteGw('gateway din fisier', { AERE_PQGW_CONFIG: fis, AERE_PQGW_MODE: 'hybrid-only' }); try { const s = await stare(g.port); cere(s.mode === 'hybrid-only', `mode ${s.mode} (mediul trebuia sa castige fata de fisier)`); const r = await cerere({ port: g.port, cale: '/o' }); cere(r.status === 200 && json(r).url === '/o', `cererea prin gateway-ul din fisier: ${r.status}`); } finally { g.p.kill(); } // exemplul publicat trebuie sa fie o configuratie pe care gateway-ul chiar o accepta const mod = await import(pathToFileURL(GATEWAY).href); const ex = mod.citesteConfig({ AERE_PQGW_CONFIG: path.join(AICI, 'pq-gateway.example.json') }); cere(ex.mode === 'hybrid-preferred' && ex.port === 8443, `exemplul citit: ${JSON.stringify({ mode: ex.mode, port: ex.port })}`); return 'fisier citit, MODE din mediu aplicat; pq-gateway.example.json acceptat'; }); await test('(p) configuratie gresita: refuza sa porneasca, cu motivul numit, si nu asculta', async () => { const altaCheie = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }); const caleAlta = path.join(TMP, 'alta-cheie.pem'); fs.writeFileSync(caleAlta, altaCheie); const fisRau = path.join(TMP, 'config-rau.json'); fs.writeFileSync(fisRau, JSON.stringify({ upstrem: 'http://x.invalid' })); const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` }; const cazuri = [ ['fara MODE', { ...baza }, 2, /MODE is required/], ['MODE necunoscut', { ...baza, AERE_PQGW_MODE: 'classic' }, 2, /MODE must be/], ['cheie in fisier gresita', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_CONFIG: fisRau }, 2, /unknown key in config file: upstrem/], ['cheia nu se potriveste cu certificatul', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_KEY: caleAlta }, 1, /KEY does not match/], ]; const note = []; for (const [nume, env, codAsteptat, motiv] of cazuri) { const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(env), timeout: 10000, encoding: 'utf8' }); cere(r.status === codAsteptat, `${nume}: cod ${r.status}, asteptat ${codAsteptat}; stderr ${String(r.stderr).slice(0, 160)}`); cere(motiv.test(r.stderr), `${nume}: motivul lipseste din stderr: ${String(r.stderr).slice(0, 160)}`); cere(!/"event":"listening"/.test(r.stdout), `${nume}: a ascultat totusi`); cere(!String(r.stderr).includes(caleAlta) && !String(r.stderr).includes(CALE_CHEIE), `${nume}: calea cheii apare in mesaj`); note.push(`${nume}: ${r.status}`); } return note.join('; '); }); await test('(l) oprire curata: cererea in curs se termina, conexiunile noi sunt refuzate, oprirea se incheie singura', async () => { const mod = await import(pathToFileURL(GATEWAY).href); const cfg = mod.citesteConfig({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_MODE: 'hybrid-only' }); const gw = await mod.pornesteGateway(cfg, { jurnal: () => {} }); const port = gw.adresa.port; const inCurs = cerere({ port, cale: '/slow?ms=1200', timeoutMs: 9000 }).then((r) => r, (e) => ({ eroare: e })); await dormi(400); const t0 = Date.now(); const oprit = gw.opreste(5000).then(() => Date.now() - t0); await dormi(100); const nou = await strangere({ port, curbe: 'X25519MLKEM768' }); const r = await inCurs; const msOprire = await Promise.race([oprit, dormi(8000).then(() => null)]); cere(!r.eroare, `cererea in curs a fost taiata: ${r.eroare && r.eroare.message}`); cere(r.status === 200 && json(r).url === '/slow?ms=1200', `cererea in curs: status ${r.status}`); cere(String(r.anteturi.connection).toLowerCase() === 'close', `raspunsul din timpul opririi are Connection: ${r.anteturi.connection}`); cere(!nou.ok && nou.cod === 'ECONNREFUSED', `o conexiune noua dupa oprire: ${nou.ok ? 'ACCEPTATA' : nou.cod}`); cere(msOprire !== null && msOprire < 4000, `oprirea nu s-a incheiat (${msOprire} ms)`); return `cererea in curs 200, conexiune noua ${nou.cod}, oprire in ${msOprire} ms`; }); // ---------------------------------------------------------------- autentificarea post-cuantica (2026-09-25) function pornireRefuzata(extra) { const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(extra), encoding: 'utf8', timeout: 15000 }); return { cod: r.status, stderr: r.stderr || '', stdout: r.stdout || '' }; } await test('(s) lant ML-DSA emis de aere-pq-pki + REQUIRE_PQ_AUTH: porneste, starea spune post-quantum, openssl vede mldsa65 + X25519MLKEM768 si verifica lantul', async () => { // AERE_PQ_PKI: controlul negativ ruleaza proba dintr-o copie a dosarului, deci calea autoritatii vine din mediu const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href); const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65'); const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Root' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 30 }); const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Issuing' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 30 }); const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] }); const cRoot = path.join(TMP, 'pq-root.pem'), cLant = path.join(TMP, 'pq-lant.pem'), cCheie = path.join(TMP, 'pq-cheie.pem'); fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root)); fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf) + P.pem('CERTIFICATE', inter)); fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' })); const g = await pornesteGw('gateway cu lant ML-DSA', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' }); const radacina = fs.readFileSync(cRoot); const s = await stare(g.port, radacina); cere(s.certificate.authentication === 'post-quantum', `autentificarea din stare: ${s.certificate.authentication}`); cere(s.certificate.chain.length === 2 && s.certificate.chain.every((c) => c.publicKeyAlgorithm === 'ML-DSA-65'), `lantul din stare: ${JSON.stringify(s.certificate.chain)}`); cere(s.certificate.chain[1].signatureAlgorithm === 'ML-DSA-87', `semnatura radacinii pe intermediar: ${s.certificate.chain[1].signatureAlgorithm}`); const o = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-verify_hostname', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n'); const t = o.o + o.e; cere(/Verify return code: 0 \(ok\)/.test(t), `openssl verificarea: ${t.slice(0, 300)}`); cere(/Peer signature type: mldsa65/.test(t), 'semnatura serverului nu e mldsa65'); cere(/Negotiated TLS1.3 group: X25519MLKEM768/.test(t), 'grupul nu e X25519MLKEM768'); const r = await cerere({ port: g.port, cale: '/ecou', ca: radacina }); cere(r.status === 200, `cererea prin gateway: ${r.status}`); return 'post-quantum, mldsa65 + X25519MLKEM768, lantul verificat de openssl'; }); await test('(s2) REQUIRE_PQ_AUTH cu certificatul clasic (EC P-256): refuza sa porneasca, motivul numeste lantul classical; fara optiune starea spune classical', async () => { const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: 'true' }); cere(r.cod === 1, `cod ${r.cod}`); cere(/REQUIRE_PQ_AUTH is set but the served chain is classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 200)}`); cere(!/"event":"listening"/.test(r.stdout), 'a ascultat totusi'); const s = await stare(GH.port); cere(s.certificate.authentication === 'classical', `starea gateway-ului clasic: ${s.certificate.authentication}`); return 'refuzat la pornire; starea clasicului: classical'; }); await test('(s3) lant MIXT (frunza ML-DSA semnata de o autoritate EC, facut de openssl): REQUIRE_PQ_AUTH refuza, cu "mixed" si semnatura numita', async () => { const k = (n) => path.join(TMP, n); execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', k('ecca.key'), '-out', k('ecca.pem'), '-days', '2', '-subj', '/CN=EC CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], { stdio: ['ignore', 'pipe', 'pipe'] }); execFileSync(OPENSSL, ['req', '-new', '-newkey', 'mldsa65', '-nodes', '-keyout', k('mix.key'), '-out', k('mix.csr'), '-subj', '/CN=localhost'], { stdio: ['ignore', 'pipe', 'pipe'] }); execFileSync(OPENSSL, ['x509', '-req', '-in', k('mix.csr'), '-CA', k('ecca.pem'), '-CAkey', k('ecca.key'), '-out', k('mix.pem'), '-days', '2', '-set_serial', '9'], { stdio: ['ignore', 'pipe', 'pipe'] }); const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: k('mix.pem'), AERE_PQGW_KEY: k('mix.key'), AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' }); cere(r.cod === 1, `cod ${r.cod}; ${r.stderr.slice(0, 200)}`); cere(/the served chain is mixed/.test(r.stderr) && /ML-DSA-65 key signed with classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 260)}`); return 'mixed: cheie ML-DSA-65, semnatura clasica'; }); // ---------------------------------------------------------------- revizuirea adversariala din 2026-09-25 (A8, A9) await test('(e3) antetele de identitate: X-Real-IP e al gateway-ului (nu al clientului), X-Client-* si X-SSL-* ale clientului NU ajung', async () => { const r = await cerere({ port: GH.port, cale: '/e3', anteturi: { 'X-Real-IP': '1.2.3.4', 'X-Client-Cert': 'fals', 'X-SSL-Client-S-DN': 'CN=fals', 'X-Client-Verify': 'SUCCESS', 'X-End-To-End': 'da', } }); cere(r.status === 200, `status ${r.status}`); const h = json(r).headers; cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic'); cere(h['x-real-ip'] === '127.0.0.1', `X-Real-IP la upstream: ${h['x-real-ip']} (trebuia adresa din socket, nu a clientului)`); const scapate = ['x-client-cert', 'x-ssl-client-s-dn', 'x-client-verify'].filter((n) => n in h); cere(scapate.length === 0, `antete de identitate ale clientului ajunse la upstream: ${scapate.join(', ')}`); return 'X-Real-IP din socket; x-client-cert, x-ssl-client-s-dn, x-client-verify oprite'; }); await test('(g3) lant ML-DSA + client fara algoritm de semnatura PQ (numai ECDSA/RSA-PSS, ca browserele de azi): refuzat si numarat "no shared signature algorithm"', async () => { const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href); const kr = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65'); const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'G3 Root' }, publicKey: kr.publicKey, ca: true, pathLen: 0, days: 5 }); const leaf = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], eku: ['serverAuth'] }); const cLant = path.join(TMP, 'g3-lant.pem'), cCheie = path.join(TMP, 'g3-cheie.pem'), cRoot = path.join(TMP, 'g3-root.pem'); fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf)); fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root)); fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' })); const g = await pornesteGw('gateway ML-DSA pentru (g3)', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' }); const radacina = fs.readFileSync(cRoot); // controlul pozitiv: cu algoritmii impliciti (care includ mldsa65) strangerea reuseste // s_client tipareste "Verify return code: 0 (ok)" SI cand strangerea a cazut inainte de orice verificare (masurat 2026-09-25: // alerta 40, 7 octeti cititi, si tot "0 (ok)"); succesul se citeste din "Peer signature type", refuzul din alerta. const bun = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n'); cere(/Peer signature type: mldsa65/.test(bun.o + bun.e) && /Verify return code: 0 \(ok\)/.test(bun.o + bun.e), 'controlul pozitiv (client cu mldsa65) nu a reusit'); const rau = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-groups', 'X25519MLKEM768', '-sigalgs', 'ECDSA+SHA256:RSA-PSS+SHA256'], 'Q\n'); const tr = rau.o + rau.e; cere(/alert handshake failure|SSL alert number 40/.test(tr) && !/Peer signature type:/.test(tr), `clientul fara algoritm PQ de semnatura nu a fost refuzat cu alerta: ${tr.split('\n').filter((l) => /alert|Peer signature|Negotiated/.test(l)).join(' | ').slice(0, 200)}`); const s = await asteaptaStare(g.port, (x) => (x.counters.handshakesRefused['no shared signature algorithm'] || 0) >= 1, 4000, radacina); cere(s && s.counters.handshakesRefused['no shared signature algorithm'] >= 1, `refuzul nu e numarat sub motivul lui: ${JSON.stringify(s && s.counters.handshakesRefused)}`); return `numarat: no shared signature algorithm = ${s.counters.handshakesRefused['no shared signature algorithm']}`; }); console.log(`PROBA PQ-GATEWAY: ${treceri} treceri, ${esecuri} esecuri${nemasurate ? `, ${nemasurate} nemasurate` : ''}`); process.exitCode = esecuri ? 1 : 0; suitaTerminata = true; curata();