'use strict'; // AERE Quantum Security Control Plane - planificatorul de migrare (B1 din roadmap, milestone 1). // // De la "detectam problema" la "detectam + spunem exact ce se schimba, in ce ordine, si ce putem migra automat". Ia iesirea celor // doua unelte pe care le avem deja - inventarul criptografic (tools/crypto-inventory, findings brute per aparitie) si scanerul de // pregatire PQ al unui hostname (cloud-gateway/readiness-service, findings de TLS) - si produce un PLAN: pentru fiecare asset // vulnerabil cuantic, actiunea concreta, tinta, metoda (auto prin produsul AERE potrivit sau manual), prioritatea si blocantul. // NU executa nimic aici (executia = executa-migrare.mjs, cu consimtamant pe actiune); planifica si numeste. // // Prioritatea respecta ce stim CORECT si multe unelte gresesc (memorie, contraexemplu Cellframe 2026-08-07): HNDL (harvest now, // decrypt later) se aplica SCHIMBULUI DE CHEIE si datelor cifrate, NU semnaturilor - o semnatura e publica, nu se recolteaza. Deci // un schimb de cheie clasic (ECDH/X25519/RSA-KEM) e URGENT (traficul de azi se decripteaza maine), o semnatura clasica pe un // certificat de lunga durata e IMPORTANTA (falsificare cand vine cuantica), iar o semnatura efemera e mai putin urgenta. // // Forma planului, versiunea 2 (2026-09-29, pentru publicare): chei si valori in ENGLEZA, fiindca planul il citeste clientul // { version, summary: { total, byUrgency, autoAere, manual, blocked, byProduct }, actions: [ { ref, asset, problem, current, target, // method: 'auto-aere'|'manual'|'blocked', product: 'gateway'|'kms'|'pki'|null, how, cn?, primitive?, urgency: 'CRITICAL'|'HIGH'| // 'MEDIUM'|'LOW', blocker, location, source: 'inventory'|'scan'|'scan-unknown-id' } ] } export const VERSIUNE = 'aere-control-plane/plan/2 (2026-09-29)'; // tintele post-cuantice ale casei, si ce produs AERE le pune const TINTE = { 'key-agree': { target: 'X25519MLKEM768 (hybrid)', product: 'gateway', how: 'hybrid TLS 1.3 termination in front of the service (PQ Gateway), without rewriting the application' }, kem: { target: 'ML-KEM-768 (hybrid with X25519)', product: 'kms', how: 'hybrid X25519 + ML-KEM-768 envelope through the KMS' }, signature: { target: 'ML-DSA-65 (hybrid with the classical scheme)', product: 'pki', how: 'hybrid certificate and key issued by the PQ PKI' }, pke: { target: 'ML-KEM-768 (hybrid KEM) instead of public-key encryption', product: 'kms', how: 'hybrid envelope through the KMS' }, }; // clasificarea unui asset vulnerabil in urgenta, pe natura primitivei (HNDL vs falsificare) function urgenta(prim, expusPublic, certLunga) { if (prim === 'key-agree' || prim === 'kem' || prim === 'pke') return expusPublic ? 'CRITICAL' : 'HIGH'; // HNDL: datele de azi se recolteaza if (prim === 'signature') return certLunga ? 'HIGH' : 'MEDIUM'; // falsificare cand vine cuantica; nu HNDL return 'MEDIUM'; } export const RANG = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; // e vulnerabil cuantic? (din inventar: g.quantumVulnerable; sau din nume de algoritm clasic) const CLASIC_VULNERABIL = /\b(rsa|ecdsa|ecdh|ecdhe|x25519|x448|ed25519|ed448|dh|dsa|secp256|secp384|secp521|nistp|brainpool)\b/i; function vulnerabil(f) { if (typeof f.quantumVulnerable === 'boolean') return f.quantumVulnerable; const n = (f.name || '') + ' ' + (f.parameterSetIdentifier || '') + ' ' + (f.curve || ''); // deja post-cuantic? if (/\b(ml-kem|ml-dsa|slh-dsa|kyber|dilithium|falcon|sphincs|frodo|mlkem|mldsa|slhdsa)\b/i.test(n)) return false; return CLASIC_VULNERABIL.test(n); } // Un CERTIFICAT autentifica, deci primitiva lui e semnatura. Inventarul REAL (tools/crypto-inventory) da insa primitiva CHEII // certificatului, onest la nivelul cheii: 'unknown' pentru RSA, 'other' pentru EC (masurat 2026-09-27 pe certificate openssl reale). // Regula veche (`f.primitive || (certificat ? 'signature' : ...)`) nu se aplica niciodata pe forma reala, deci FIECARE certificat iesea // "manual, MEDIE, de stabilit". Decizia sta aici, intr-un singur loc; adaptorul din control-plane.mjs trece primitiva neatinsa. const PRIMITIVE_NEDECISE = new Set(['', 'unknown', 'other']); function primitivaDe(f) { const p = f.primitive || ''; if (f.assetType === 'certificate' && PRIMITIVE_NEDECISE.has(p)) return 'signature'; return p || 'unknown'; } // CN-ul certificatului (subjectName ca "C=US, O=X, CN=api.intern"): inlocuitorul PQ se emite pentru ACELASI nume, nu pentru unul // inventat de executor. Numai un nume de gazda exact; un wildcard sau lipsa CN-ului lasa actiunea manuala, cu motivul scris. const NUME_GAZDA = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; function cnDin(subjectName) { const m = /(?:^|[,/]\s*)CN\s*=\s*([^,/]+)/.exec(String(subjectName || '')); return m ? m[1].trim() : null; } function actiuneDinInventar(f) { if (!vulnerabil(f)) return null; const prim = primitivaDe(f); const certLunga = f.assetType === 'certificate' && f.certificate && f.certificate.notValidAfter && (new Date(f.certificate.notValidAfter) - new Date()) > 365 * 24 * 3600 * 1000; const t = TINTE[prim] || { target: 'hybrid post-quantum equivalent', product: null, how: 'to be decided' }; const numeAsset = f.assetType === 'certificate' ? `X.509 certificate ${f.certificate && f.certificate.subjectName ? '(' + f.certificate.subjectName + ')' : ''}`.trim() : `${f.name || prim}${f.assetType === 'related-crypto-material' && f.material ? ' (' + f.material.type + ')' : ''}`; // blocant onest: certificatele WebPKI publice nu se pot emite PQ azi (niciun CA public nu emite), deci manual/blocat const webpkiPublic = f.assetType === 'certificate' && f.certificate && /\b(let's encrypt|digicert|globalsign|sectigo|google trust|amazon)\b/i.test((f.certificate.issuerName || '')); let method = t.product ? 'auto-aere' : 'manual'; let blocker = null; let cn; let how = t.how; if (f.assetType === 'certificate' && !webpkiPublic) { const c = cnDin(f.certificate && f.certificate.subjectName); if (c && NUME_GAZDA.test(c)) cn = c.toLowerCase(); else if (method === 'auto-aere') { method = 'manual'; blocker = c && c.startsWith('*.') ? `wildcard certificate (${c}): the PQ PKI issues exact names only; issue one certificate for each name served` : 'the certificate has no CN that is a host name; set the names served (SAN) from the configuration before issuing'; } } if (f.assetType === 'related-crypto-material' && f.material && f.material.type === 'private-key') { how = 'replace the private key together with the certificate or protocol that uses it (its use is not visible from the file)'; } if (webpkiPublic) { method = 'blocked'; blocker = 'public WebPKI certificate: no public CA issues post-quantum certificates yet; it stays classical until then, or the service moves behind the PQ Gateway'; } return { ref: f.ref || `${f.assetType || 'algorithm'}:${(f.name || prim)}`, asset: numeAsset, problem: `quantum-vulnerable ${prim}${f.assetType === 'certificate' ? '' : ' (' + (f.name || '?') + ')'}`, current: f.name || (f.certificate && f.certificate.subjectName) || prim, target: t.target, method, product: method === 'auto-aere' ? t.product : null, how, ...(cn ? { cn } : {}), primitive: prim, urgency: urgenta(prim, false, certLunga), blocker, location: f.location || null, source: 'inventory', }; } // din findings de scanare (readiness-service): fiecare finding are {id, severity, title, detail, recommendation}. // 2026-09-27: clasificatorul vechi potrivea id-uri PRESUPUSE cu expresii regulate, iar proba lui folosea tot id-uri inventate // ('tls-kex-classical'), deci iesea verde pe un defect. Acum un tabel INCHIS pe id-urile scanerului (proba cere ca fiecare id din // sursa scanerului sa fie in tabel); regula veche ramane doar pentru un id necunoscut, marcata `source: 'scan-unknown-id'`. const URG_DIN_SEV = (s) => ({ high: 'HIGH', medium: 'MEDIUM', low: 'LOW' }[String(s || '').toLowerCase()] || 'MEDIUM'); export const CLASIFICARE_SCAN = { // schimb de cheie clasic pe un endpoint public = HNDL, singurul CRITIC; PQ Gateway il inchide fara rescrierea aplicatiei 'hndl-exposed': (f, d) => ({ ref: `tls-kex:${d}`, asset: `TLS on ${d}`, problem: f.title || 'classical key exchange (no ML-KEM)', current: 'X25519/ECDHE (classical)', target: TINTE['key-agree'].target, method: 'auto-aere', product: 'gateway', how: TINTE['key-agree'].how, urgency: 'CRITICAL', blocker: null }), // fara TLS 1.3 nu exista schimb de cheie PQ deloc; se repara pe server (gateway-ul, pus pentru hndl-exposed, il termina oricum) 'tls13-missing': (f, d) => ({ ref: `tls-conf:${d}:tls13-missing`, asset: `TLS configuration of ${d}`, problem: f.title || 'TLS 1.3 is not offered', current: 'TLS 1.2 only', target: 'TLS 1.3 (the precondition of a hybrid key exchange)', method: 'manual', product: null, how: 'enable TLS 1.3 on the server; a PQ Gateway placed in front (the tls-kex action) terminates hybrid TLS 1.3 anyway', urgency: 'HIGH', blocker: null }), // PQ e DEJA suportat; clientii care il ofera primul il primesc. Nu e expunere HNDL, e ordinea grupurilor pe server 'pq-not-preferred': (f, d) => ({ ref: `tls-conf:${d}:pq-not-preferred`, asset: `TLS configuration of ${d}`, problem: f.title || 'post-quantum supported but not preferred', current: 'X25519MLKEM768 accepted, but not chosen first', target: 'X25519MLKEM768 first in the group list', method: 'manual', product: null, how: f.recommendation || 'put X25519MLKEM768 first in the server\'s group list', urgency: 'MEDIUM', blocker: null }), 'pq-preference-unmeasured': () => null, // informativ: PQ suportat, preferinta nemasurabila cu metoda scanerului 'tls12-accepted': (f, d) => ({ ref: `tls-conf:${d}:tls12-accepted`, asset: `TLS configuration of ${d}`, problem: f.title || 'TLS 1.2 still accepted', current: f.detail || 'TLS 1.2 accepted', target: 'TLS 1.3 only', method: 'manual', product: null, how: f.recommendation || 'disable TLS 1.2 once the old clients have moved', urgency: URG_DIN_SEV(f.severity), blocker: null }), 'hsts-missing': (f, d) => ({ ref: `tls-conf:${d}:hsts-missing`, asset: `HTTP configuration of ${d}`, problem: f.title || 'no HSTS', current: 'no Strict-Transport-Security', target: 'HSTS', method: 'manual', product: null, how: f.recommendation || 'send Strict-Transport-Security', urgency: URG_DIN_SEV(f.severity), blocker: null }), 'cert-expiring': (f, d) => ({ ref: `tls-cert-expiry:${d}`, asset: `TLS certificate of ${d}`, problem: f.title || 'the certificate expires soon', current: f.detail || 'close to expiry', target: 'renewed certificate, renewed automatically', method: 'manual', product: null, how: f.recommendation || 'renew now and automate the renewal (ACME)', urgency: URG_DIN_SEV(f.severity), blocker: null }), 'rsa-short': (f, d) => ({ ref: `tls-cert-rsa:${d}`, asset: `key of the TLS certificate of ${d}`, problem: f.title || 'short RSA key', current: 'RSA below 3072 bits', target: 'RSA-3072 or more, or ECDSA P-256 (still classical; post-quantum is blocked by the public CAs)', method: 'manual', product: null, how: f.recommendation || 'reissue with a longer key', urgency: URG_DIN_SEV(f.severity), blocker: null }), // autentificarea certificatului e clasica: adevarat pentru ORICE certificat WebPKI azi. Blocat onest, nu CRITIC: HNDL nu se aplica // semnaturilor, iar PQ Gateway NU face autentificarea post-cuantica (face numai schimbul de cheie) 'auth-classical': (f, d) => ({ ref: `tls-cert:${d}`, asset: `TLS certificate of ${d}`, problem: f.title || 'classical authentication', current: 'ECDSA/RSA', target: TINTE.signature.target, method: 'blocked', product: null, how: 'no public CA issues post-quantum certificates today; it stays classical until then (a future forgery, not a harvest today)', urgency: 'MEDIUM', blocker: 'public CA without post-quantum' }), 'chain-untrusted': (f, d) => ({ ref: `tls-conf:${d}:chain-untrusted`, asset: `certificate chain of ${d}`, problem: f.title || 'untrusted chain', current: f.detail || 'incomplete or untrusted chain', target: 'complete chain, up to a standard root', method: 'manual', product: null, how: f.recommendation || 'serve the complete chain', urgency: URG_DIN_SEV(f.severity), blocker: null }), }; function actiuneDinScan(fnd, domain) { const id = String(fnd.id || ''); if (Object.hasOwn(CLASIFICARE_SCAN, id)) { const a = CLASIFICARE_SCAN[id](fnd, domain); return a ? { ...a, source: 'scan' } : null; } // id necunoscut: regula veche, pe cuvinte, dar MARCATA - nu e o clasificare facuta de noi pe forma reala a scanerului const lid = id.toLowerCase(); const sev = (fnd.severity || '').toLowerCase(); if (/kex|key.?exchange|kem|handshake/.test(lid) || /key exchange/i.test(fnd.title || '')) { return { ...CLASIFICARE_SCAN['hndl-exposed'](fnd, domain), source: 'scan-unknown-id' }; } if (/tls1?2|tls_1_2|hsts|downgrade|protocol/.test(lid)) { return { ref: `tls-conf:${domain}:${lid}`, asset: `TLS configuration of ${domain}`, problem: fnd.title || 'weak TLS configuration', current: fnd.detail || '?', target: 'TLS 1.3 + HSTS', method: 'manual', product: null, how: fnd.recommendation || 'harden the server configuration', urgency: sev === 'high' ? 'HIGH' : 'MEDIUM', blocker: null, source: 'scan-unknown-id' }; } return null; // informativ sau necunoscut fara indiciu: nu devine actiune } /** * planeaza({ inventory, scan }) -> { version, summary, actions } (generatedAt il pune apelantul) * inventory: array de findings crypto-inventory, trecute prin adaptorul din control-plane.mjs. scan: { domain, findings } sau array. */ export function planeaza({ inventory = [], scan = null } = {}) { const actiuni = []; for (const f of inventory) { const a = actiuneDinInventar(f); if (a) actiuni.push(a); } if (scan) { const domain = scan.domain || 'target'; const fnds = Array.isArray(scan) ? scan : (scan.findings || []); for (const fnd of fnds) { const a = actiuneDinScan(fnd, domain); if (a) actiuni.push(a); } } // dedup pe ref, sortare pe urgenta apoi metoda (auto inainte de manual: livrezi intai ce se poate) const vazut = new Set(); const dedup = []; for (const a of actiuni) { if (vazut.has(a.ref)) continue; vazut.add(a.ref); dedup.push(a); } dedup.sort((x, y) => (RANG[x.urgency] - RANG[y.urgency]) || ((x.method === 'auto-aere' ? 0 : 1) - (y.method === 'auto-aere' ? 0 : 1))); const summary = { total: dedup.length, byUrgency: dedup.reduce((m, a) => (m[a.urgency] = (m[a.urgency] || 0) + 1, m), {}), autoAere: dedup.filter((a) => a.method === 'auto-aere').length, manual: dedup.filter((a) => a.method === 'manual').length, blocked: dedup.filter((a) => a.method === 'blocked').length, byProduct: dedup.filter((a) => a.product).reduce((m, a) => (m[a.product] = (m[a.product] || 0) + 1, m), {}), }; return { version: VERSIUNE, summary, actions: dedup }; } export { planeaza as planMigration };