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

195 lines
15 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' },
};
// 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 };