aere-quantum/control-plane/plan-migrare.mjs

227 lines
18 KiB
JavaScript

'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' },
};
// 2026-10-18 (prima rulare pe un arbore strain, Avano): un X25519, un Ed25519 sau un RSA chemat de APLICATIE (ratchet, PQXDH,
// ntor, semnaturi de mesaj, chei derivate) nu se repara punand un gateway TLS in fata serviciului si nici emitand un certificat:
// gateway-ul vede numai TLS-ul, PKI-ul numai certificatele. Numai o folosire de TLS/certificat din cod se trimite la produs; restul
// e lucru IN COD, manual, cu tinta hibrida scrisa. O constatare fara `api` (ECDH generic, fara context) ramane pe drumul vechi:
// contextul ei nu se stie, nu se ghiceste.
const IN_TLS = /\b(tls|ssl|https|x509|SSLContext|SslContext|ecdhCurve|CurvePreferences|namedGroups|KeyStore|keytool|rustls|SslConnector|SslAcceptor|TlsConnector|TlsAcceptor|HttpsConnectorBuilder)\b/i;
const TINTE_IN_COD = {
'key-agree': { target: 'X25519 + ML-KEM-768 in the same handshake and key schedule (hybrid KEM, as PQXDH does)', product: null,
how: 'called by the application itself, not by a TLS edge: a gateway in front cannot change it. Combine it with ML-KEM-768 in the protocol (both shared secrets into the KDF); if the protocol already pairs it with a post-quantum KEM in the same handshake, record that as the evidence and close this action.' },
};
// semnaturile si cifrarea cu cheie publica din cod raman pe PKI/KMS (produsul emite cheia hibrida, real, prin executor), dar `how`
// spune ca APLICATIA trebuie sa o adopte: produsul da cheia, nu schimba apelul din cod
const NOTA_ADOPTARE = {
signature: ' The application signs with its own key here, so after the PQ PKI issues the hybrid key and certificate the signing call must be changed to use them (a code change; the PKI provides the key, not the call site).',
pke: ' The application encrypts with its own public key here, so after the KMS holds the hybrid key the call must be changed to use the KMS envelope (a code change; the KMS provides the key, not the call site).',
};
// TLS de CLIENT configurat in aplicatie: gateway-ul (terminare de server) nu schimba ce ofera clientul; se repara in client
const TLS_CLIENT = /\b(default_provider|TlsConnector|ClientConfig|HttpsConnectorBuilder|SslConnector)\b/;
function tlsClientInCod(f) {
return !!(f.language && f.api) && TLS_CLIENT.test(String(f.api)) && f.assetType !== 'certificate' && f.assetType !== 'related-crypto-material';
}
function inCod(f) {
return !!(f.language && f.api) && !IN_TLS.test(String(f.api)) && f.protocolType !== 'tls'
&& f.assetType !== 'certificate' && f.assetType !== 'related-crypto-material';
}
// 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 eInCod = inCod(f);
const tInCod = (eInCod && TINTE_IN_COD[prim])
|| (tlsClientInCod(f) && (prim === 'key-agree' || prim === 'kem') && { target: 'X25519MLKEM768 offered by this client (hybrid)', product: null,
how: 'client-side TLS configured by the application itself: a PQ Gateway in front of a server does not change what this client offers. Switch the client to a provider that offers X25519MLKEM768 (rustls: aws-lc-rs, 0.23.22 or later; OpenSSL 3.5 or later) or enable the hybrid group in its configuration, then re-run the inventory.' });
const t0 = TINTE[prim] || { target: 'hybrid post-quantum equivalent', product: null, how: 'to be decided' };
const t = tInCod ? { ...tInCod, how: `${f.name || prim} via ${f.api}: ${tInCod.how}` }
: (eInCod && NOTA_ADOPTARE[prim] ? { ...t0, how: t0.how + NOTA_ADOPTARE[prim] } : t0);
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 };