// AERE Quantum Security Control Plane - EXECUTORUL migrarii (B1, milestone 4: "migrare automata cu proba si intoarcere"). // // Ia PLANUL produs de plan-migrare.mjs si, pentru fiecare actiune `auto-aere` la care clientul a CONSIMTIT explicit, executa // migrarea prin produsul AERE potrivit, masoara starea DUPA, si scrie o inregistrare intr-un lant de hash-uri verificabil: // gateway (schimb de cheie clasic, HNDL) -> porneste PQ Gateway in fata serviciului si DOVEDESTE cu o strangere de mana TLS 1.3 // care ofera NUMAI X25519MLKEM768; scrie configuratia pentru managerul de servicii al // clientului; la esec opreste gateway-ul (intoarcere) // kms (KEM / cifrare cu cheie publica) -> creeaza cheia hibrida (X25519 + ML-KEM-768) in KMS, sau o ROTESTE daca exista; // versiunile vechi raman decriptabile, deci intoarcerea e "nu folosi versiunea noua" // pki (semnatura, certificat) -> emite un certificat ML-DSA-65 din CA-ul PQ al clientului si il VERIFICA pe lant pana // la radacina (verifyChain); fisierele se calculeaza in memorie si se sterg la esec // PRINCIPII, si fiecare a costat undeva: (1) nimic nu se executa fara consimtamant PE ACTIUNE (`consent` = multime de ref sau 'all'); // (2) modul implicit e USCAT (dry run) - executia se cere cu `execute: true`; (3) verdictul fiecarei actiuni vine dintr-o // MASURATOARE de dupa, nu din "comanda a iesit cu 0"; (4) o actiune cazuta nu opreste restul si isi face intoarcerea ei; (5) nicio // valoare secreta nu ajunge in inregistrari sau in erori; (6) inregistrarile sunt un lant sha256(seq|prev|inregistrare) pe care // consola (consola.mjs) si oricine altcineva il poate re-verifica. // CE NU FACE: nu schimba DNS-ul, porturile sau firewall-ul clientului, nu emite certificate WebPKI publice, nu sterge chei din KMS. // Forma inregistrarilor, versiunea 2 (2026-09-29, pentru publicare): chei si valori in engleza (vezi README). // // node executa-migrare.mjs --plan plan.json --out [--consent ref1,ref2|all] [--execute] // [--gw-cert c.pem --gw-key k.pem --gw-upstream http://h:p [--gw-mode hybrid-only|hybrid-preferred] [--gw-listen 127.0.0.1:8443] [--gw-keep]] // [--kms-url http://127.0.0.1:8420 --kms-token-file f] [--pki-dir ca --pki-ca issuing --pki-roots ca/root.crt] (AERE_PKI_PASSPHRASE) // iesire 0 = toate actiunile consimtite au verdict OK (sau nimic de executat); 1 = cel putin una FAILED; 2 = nu s-a putut rula. import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import tls from 'node:tls'; import readline from 'node:readline'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const GATEWAY = path.join(AICI, '..', 'pq-gateway', 'pq-gateway.mjs'); export const VERSIUNE = 'aere-control-plane/execution/2 (2026-09-29)'; const GRUP_PQ = 'X25519MLKEM768'; // --- lantul de inregistrari ------------------------------------------------------------------------------------------------- export function canonic(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(canonic).join(',') + ']'; return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonic(v[k])).join(',') + '}'; } const sha = (s) => crypto.createHash('sha256').update(s).digest('hex'); export function lantulExecutiei() { const inreg = []; return { adauga(r) { const seq = inreg.length; const prev = seq ? inreg[seq - 1].hash : '0'.repeat(64); const hash = sha(`${seq}|${prev}|${canonic(r)}`); inreg.push({ seq, prev, record: r, hash }); return hash; }, lista() { return inreg.slice(); }, }; } /** Re-verifica un lant de inregistrari (sau fisierul execution.json). Intoarce {ok, seq, reason}. */ export function verificaExecutie(x) { const lista = Array.isArray(x) ? x : (x && Array.isArray(x.records) ? x.records : null); if (!lista) return { ok: false, reason: 'no records' }; let prev = '0'.repeat(64); for (let i = 0; i < lista.length; i++) { const e = lista[i]; if (!e || typeof e !== 'object') return { ok: false, seq: i, reason: `record ${i} is not an object` }; if (e.seq !== i) return { ok: false, seq: i, reason: `seq ${e.seq} instead of ${i}` }; if (e.prev !== prev) return { ok: false, seq: i, reason: 'prev does not link the previous hash' }; const h = sha(`${i}|${prev}|${canonic(e.record)}`); if (h !== e.hash) return { ok: false, seq: i, reason: 'the hash of the record does not reproduce (content changed)' }; prev = h; } return { ok: true, seq: lista.length, hash: prev }; } // --- ajutoare fara secrete -------------------------------------------------------------------------------------------------- const taie = (s) => String(s ?? '').replace(/0x[0-9a-fA-F]{20,}/g, '0x…').replace(/[A-Za-z0-9+/=]{48,}/g, '…').slice(0, 300); // Numele resursei unei actiuni (cheia KMS, fisierele, CN-ul implicit) = un prefix lizibil + 16 hex din sha256(ref). Forma veche (ref-ul // curatat si taiat la 40) dadea ACELASI nume pentru doua ref-uri reale din acelasi fisier, deci consimtamantul dat unuia ROTEA cheia // celuilalt (revizuirea din 2026-09-27, reprodusa). Prefixul se taie fara '-' la capete, deci numele e si o eticheta DNS valida (CN) // si un nume KMS valid (`^[a-z0-9][a-z0-9_-]{0,63}$`): cel mult 45 de caractere. export function numeMigrare(ref) { const pref = String(ref).replace(/[^a-z0-9]+/gi, '-').toLowerCase().slice(0, 24).replace(/^-+|-+$/g, ''); return 'mig-' + (pref ? pref + '-' : '') + sha(String(ref)).slice(0, 16); } const san = numeMigrare; export function citesteToken(f) { const t = fs.readFileSync(f, 'utf8').trim(); if (t.length < 32) throw new Error('the KMS token in the file is shorter than 32 characters'); return t; } // --- gateway ----------------------------------------------------------------------------------------------------------------- function pornesteGateway(env, ms = 10000) { return new Promise((resolve, reject) => { const mediu = { ...process.env }; for (const k of Object.keys(mediu)) if (k.startsWith('AERE_PQGW_')) delete mediu[k]; const p = spawn(process.execPath, [GATEWAY], { env: { ...mediu, ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: env.__keep === '1' }); let err = ''; p.stderr.on('data', (b) => { err += b; }); const t = setTimeout(() => { p.kill(); reject(new Error(`the gateway did not report 'listening' within ${ms} ms: ${taie(err)}`)); }, ms); readline.createInterface({ input: p.stdout }).on('line', (l) => { let j; try { j = JSON.parse(l); } catch { return; } if (j.event === 'listening') { clearTimeout(t); resolve({ p, port: j.port }); } }); p.on('exit', (cod) => { clearTimeout(t); reject(new Error(`the gateway exited with ${cod}: ${taie(err)}`)); }); }); } function probaTlsPq(port, { ca, servername, grupuri = GRUP_PQ }) { return new Promise((resolve) => { const s = tls.connect({ host: '127.0.0.1', port, servername, ...(ca ? { ca } : {}), minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: grupuri }, () => { const eki = s.getEphemeralKeyInfo(); const r = { ok: true, protocol: s.getProtocol(), group: eki && eki.name ? eki.name : null, authorized: s.authorized }; s.end(); resolve(r); }); s.setTimeout(8000, () => s.destroy(new Error('the handshake did not finish within 8 s'))); s.on('error', (e) => resolve({ ok: false, code: e.code, message: taie(e.message) })); }); } async function executaGateway(a, o, rec) { const g = o.gateway || {}; for (const [k, v] of [['cert', g.cert], ['key', g.key], ['upstream', g.upstream]]) if (!v) throw new Error(`gateway: missing ${k}`); if (!fs.existsSync(g.cert) || !fs.existsSync(g.key)) throw new Error('gateway: the certificate or the key is not on disk'); const mode = g.mode || 'hybrid-preferred'; const listen = g.listen || '127.0.0.1:0'; const env = { AERE_PQGW_LISTEN: listen, AERE_PQGW_CERT: g.cert, AERE_PQGW_KEY: g.key, AERE_PQGW_MODE: mode, AERE_PQGW_UPSTREAM: g.upstream, __keep: g.keep ? '1' : '0' }; const { p, port } = await pornesteGateway(env); rec.steps.push({ step: 'gateway started', port, mode, upstream: g.upstream }); const ca = g.ca ? fs.readFileSync(g.ca, 'utf8') : (g.selfSigned ? fs.readFileSync(g.cert, 'utf8') : null); let proba; try { proba = await probaTlsPq(port, { ca, servername: g.servername || 'localhost' }); } finally { if (!proba || !proba.ok) { p.kill(); rec.steps.push({ step: 'ROLLBACK: gateway stopped (the TLS test failed)' }); } } // Ce dovedeste proba, spus exact (revizuirea adversariala 2026-09-27): un CLIENT care ofera numai X25519MLKEM768 a terminat // strangerea, deci serverul stie schimbul hibrid. Daca serverul REFUZA clasicul o masoara a doua proba, cu un client numai clasic. rec.steps.push({ step: `TLS 1.3 test: a client offering ONLY ${GRUP_PQ} completed the handshake`, ok: proba.ok, group: proba.group ?? `(structural: the client offered only ${GRUP_PQ})`, protocol: proba.protocol ?? null, ...(proba.ok ? {} : { reason: proba.message || proba.code }) }); if (!proba.ok) throw new Error('the post-quantum TLS test failed: ' + (proba.message || proba.code)); const clasic = await probaTlsPq(port, { ca, servername: g.servername || 'localhost', grupuri: 'X25519:P-256' }); rec.steps.push({ step: 'TLS 1.3 test: a client offering ONLY classical groups (X25519, P-256)', accepted: clasic.ok }); if (mode === 'hybrid-only' && clasic.ok) { p.kill(); rec.steps.push({ step: 'ROLLBACK: gateway stopped (hybrid-only accepted a classical client)' }); throw new Error('hybrid-only accepted a classical-only client: the structural guarantee does not hold'); } const cfg = path.join(o.out, san(a.ref) + '.gateway.env'); fs.writeFileSync(cfg, Object.entries(env).filter(([k]) => k.startsWith('AERE_PQGW_')).map(([k, v]) => `${k}=${v}`).join('\n') + '\n', { mode: 0o600 }); rec.steps.push({ step: 'gateway configuration written for the service manager', file: cfg }); if (g.keep) { p.unref(); rec.steps.push({ step: 'gateway left running', pid: p.pid, port }); } else { p.kill(); rec.steps.push({ step: 'gateway stopped after the test (keep=false); the operator starts it from the written configuration' }); } rec.after = { port, mode, pqSupported: true, classicalAccepted: clasic.ok, guarantee: clasic.ok ? 'classical clients still get a classical key exchange (hybrid-preferred); a guarantee that EVERY connection is hybrid needs hybrid-only' : 'every accepted connection used the hybrid key exchange (the classical one was refused, measured)' }; rec.verdict = 'OK'; } // --- kms ----------------------------------------------------------------------------------------------------------------------- async function kmsCerere(o, metoda, cale, corp) { const r = await fetch(o.kms.url.replace(/\/$/, '') + cale, { method: metoda, headers: { authorization: 'Bearer ' + o.kms.token, ...(corp ? { 'content-type': 'application/json' } : {}) }, body: corp ? JSON.stringify(corp) : undefined }); let j = null; try { j = await r.json(); } catch { j = null; } return { status: r.status, j }; } async function executaKms(a, o, rec) { if (!o.kms || !o.kms.url || !o.kms.token) throw new Error('kms: missing url or token (file)'); // numele cheii se deriva NUMAI din ref (prefix mig-), nu din plan: un camp din plan putea numi o cheie straina, existenta, care ar fi // fost ROTITA sub un ref pe care omul l-a consimtit pentru altceva (revizuirea adversariala 2026-09-27) const nume = san(a.ref); const tip = /sign|semn/i.test(a.target || '') ? 'sign' : 'encrypt'; const inainte = await kmsCerere(o, 'GET', `/v1/keys/${encodeURIComponent(nume)}`); rec.before = { exists: inainte.status === 200, version: inainte.j?.latest_version ?? null }; let r; if (inainte.status === 200) { r = await kmsCerere(o, 'POST', `/v1/keys/${encodeURIComponent(nume)}/rotate`); rec.steps.push({ step: 'the key exists: rotated', status: r.status }); } else { r = await kmsCerere(o, 'POST', `/v1/keys/${encodeURIComponent(nume)}`, { type: tip }); if (r.status === 404 || r.status === 405) r = await kmsCerere(o, 'POST', '/v1/keys', { name: nume, type: tip }); rec.steps.push({ step: `hybrid key created (${tip})`, status: r.status }); } if (r.status < 200 || r.status >= 300) throw new Error(`the KMS answered ${r.status}: ${taie(r.j?.error || r.j?.code || '')}`); const dupa = await kmsCerere(o, 'GET', `/v1/keys/${encodeURIComponent(nume)}`); if (dupa.status !== 200) throw new Error(`KMS: the key cannot be read afterwards (${dupa.status})`); const v = dupa.j.latest_version; const ver = dupa.j.versions?.[String(v)]; if (!(v >= 1) || !ver) throw new Error('KMS: no new version after the operation'); if (rec.before.exists && !(v > rec.before.version)) throw new Error('KMS: the rotation did not increase the version'); rec.after = { key: nume, type: tip, latest_version: v, fingerprint: ver.fingerprint ?? null, min_decryption_version: dupa.j.min_decryption_version ?? null }; rec.steps.push({ step: 'rollback: the older versions stay decryptable; nothing is deleted' }); rec.verdict = 'OK'; } // --- pki ----------------------------------------------------------------------------------------------------------------------- let P = null; async function pki() { if (!P) P = await import(new URL('../pq-pki/pki.mjs', import.meta.url).href); return P; } function pem(tip, der) { return `-----BEGIN ${tip}-----\n${Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${tip}-----\n`; } async function executaPki(a, o, rec) { const k = o.pki || {}; if (!k.dir || !k.ca || !k.roots) throw new Error('pki: missing dir, ca or roots'); if (!k.passphrase) throw new Error('pki: missing the CA passphrase (AERE_PKI_PASSPHRASE)'); const Pk = await pki(); const caCert = Pk.unpem(fs.readFileSync(path.join(k.dir, k.ca + '.crt'), 'utf8'))[0]; const caKey = Pk.importPrivateKey(fs.readFileSync(path.join(k.dir, k.ca + '.key'), 'utf8'), k.passphrase); const roots = Pk.unpem(fs.readFileSync(k.roots, 'utf8')); // numele algoritmului e cel al lui Node/OpenSSL, cu litere mici (pki.generateKey le cere asa); tinta din plan e scrisa "ML-DSA-65" const cn = a.cn || (san(a.ref) + '.internal'); const alg = String(k.alg || 'ml-dsa-65').toLowerCase(); if (!/^(?=.{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.test(cn)) throw new Error(`pki: the cn is not a valid host name (no wildcard): ${String(cn).slice(0, 60)}`); const kp = Pk.generateKey(alg); const cert = Pk.issue({ issuer: caCert, signingKey: caKey, subject: { cn }, publicKey: kp.publicKey, days: Number(k.days || 90), dns: [cn] }); rec.steps.push({ step: `${alg} certificate issued by the CA "${k.ca}"`, cn }); const caEsteRadacina = roots.some((r) => Buffer.compare(Buffer.from(r), Buffer.from(caCert)) === 0); const v = Pk.verifyChain({ leaf: cert, intermediates: caEsteRadacina ? [] : [caCert], roots, host: cn, purpose: 'serverAuth', requireCrl: false, crls: [] }); rec.steps.push({ step: 'the chain verified up to the root', ok: !!v.ok, ...(v.ok ? { chain: v.chain } : { code: v.code, reason: taie(v.reason) }) }); if (!v.ok) throw new Error(`the chain does not verify (${v.code}): ${taie(v.reason)}`); // ambele iesiri se calculeaza IN MEMORIE inainte de orice scriere (exportPrivateKey poate refuza o parola care a trecut la import); // abia apoi se scriu, cheia intai, si orice cadere dupa prima scriere sterge ce s-a scris (revizuirea adversariala 2026-09-27) const baza = path.join(o.out, san(a.ref)); const pemCert = pem('CERTIFICATE', cert); const pemCheie = Pk.exportPrivateKey(kp.privateKey, k.passphrase); const scrise = []; try { fs.writeFileSync(baza + '.key', pemCheie, { mode: 0o600, flag: 'wx' }); scrise.push(baza + '.key'); fs.writeFileSync(baza + '.crt', pemCert, { mode: 0o644, flag: 'wx' }); scrise.push(baza + '.crt'); } catch (e) { for (const f of scrise) { try { fs.rmSync(f, { force: true }); } catch {} } rec.steps.push({ step: 'ROLLBACK: removed the partly written files', removed: scrise.length }); throw e; } rec.steps.push({ step: 'wrote the certificate and the key SEALED under the CA passphrase', cert: baza + '.crt', key: baza + '.key' }); rec.after = { cn, alg, serial: Pk.parseCert(cert).serial.toString('hex'), chain: v.chain }; rec.verdict = 'OK'; } // --- executia ------------------------------------------------------------------------------------------------------------------ const EXECUTORI = { gateway: executaGateway, kms: executaKms, pki: executaPki }; export async function executa(plan, o) { const consimt = o.consent === 'all' ? 'all' : new Set(o.consent || []); const out = o.out; fs.mkdirSync(out, { recursive: true }); const lant = lantulExecutiei(); const log = o.log || (() => {}); lant.adauga({ type: 'start', version: VERSIUNE, at: new Date().toISOString(), mode: o.execute ? 'executed' : 'dry-run', actionsInPlan: (plan.actions || []).length }); const sumar = { total: 0, ok: 0, failed: 0, dryRun: 0, skipped: 0, notExecutable: 0 }; for (const a of plan.actions || []) { if (a.method !== 'auto-aere') { sumar.notExecutable++; continue; } sumar.total++; const rec = { ref: a.ref, product: a.product, urgency: a.urgency, asset: a.asset, target: a.target, consented: consimt === 'all' || consimt.has(a.ref), mode: o.execute ? 'executed' : 'dry-run', steps: [], verdict: null }; if (!rec.consented) { rec.verdict = 'SKIPPED-no-consent'; sumar.skipped++; lant.adauga(rec); log(` ${a.ref}: skipped (no consent)`); continue; } rec.effectiveTarget = a.product === 'kms' ? `KMS key ${san(a.ref)}` : a.product === 'pki' ? `certificate for ${a.cn || san(a.ref) + '.internal'}` : a.product === 'gateway' ? `gateway in front of ${(o.gateway && o.gateway.upstream) || '?'}` : '?'; if (!o.execute) { rec.verdict = 'DRY-RUN'; rec.steps.push({ step: `would run through ${a.product} on ${rec.effectiveTarget}: ${a.how || ''}` }); sumar.dryRun++; lant.adauga(rec); log(` ${a.ref}: dry run (${rec.effectiveTarget})`); continue; } // Object.hasOwn: `constructor`/`toString` sunt functii mostenite din Object.prototype; cu EXECUTORI[a.product] treceau de garda si // o actiune fara niciun efect iesea OK (revizuirea adversariala 2026-09-27, reprodus) const ex = Object.hasOwn(EXECUTORI, String(a.product)) ? EXECUTORI[a.product] : null; if (!ex) { rec.verdict = 'FAILED'; rec.error = `unknown product: ${a.product}`; sumar.failed++; lant.adauga(rec); continue; } try { await ex(a, o, rec); if (rec.verdict !== 'OK') throw new Error('the executor did not write a measured verdict'); sumar.ok++; log(` ${a.ref}: OK`); } catch (e) { rec.verdict = 'FAILED'; rec.error = taie(e.message); sumar.failed++; log(` ${a.ref}: FAILED (${rec.error})`); } lant.adauga(rec); } lant.adauga({ type: 'end', at: new Date().toISOString(), summary: sumar }); const dosar = { version: VERSIUNE, records: lant.lista() }; fs.writeFileSync(path.join(out, 'execution.json'), JSON.stringify(dosar, null, 1) + '\n'); return { summary: sumar, records: dosar.records, file: path.join(out, 'execution.json') }; } // --- CLI ------------------------------------------------------------------------------------------------------------------------- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i > 0 ? process.argv[i + 1] : d; }; const flag = (n) => process.argv.includes('--' + n); try { const plan = JSON.parse(fs.readFileSync(arg('plan'), 'utf8')); const c = arg('consent', ''); const consent = c === 'all' ? 'all' : c.split(',').map((s) => s.trim()).filter(Boolean); const o = { consent, execute: flag('execute'), out: arg('out', 'execution'), log: (m) => console.log(m) }; if (arg('gw-cert')) o.gateway = { cert: arg('gw-cert'), key: arg('gw-key'), upstream: arg('gw-upstream'), mode: arg('gw-mode'), listen: arg('gw-listen'), keep: flag('gw-keep'), ca: arg('gw-ca'), selfSigned: flag('gw-self-signed'), servername: arg('gw-servername') }; if (arg('kms-url')) o.kms = { url: arg('kms-url'), token: citesteToken(arg('kms-token-file')) }; if (arg('pki-dir')) o.pki = { dir: arg('pki-dir'), ca: arg('pki-ca'), roots: arg('pki-roots'), passphrase: process.env.AERE_PKI_PASSPHRASE || '' }; const r = await executa(plan, o); console.log(`execution: ${JSON.stringify(r.summary)} -> ${r.file}`); process.exitCode = r.summary.failed ? 1 : 0; } catch (e) { console.error('could not run: ' + taie(e.message)); process.exitCode = 2; } }