diff --git a/README.md b/README.md index 674fea0..2ca4d93 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ command of the verification layer, which needs `ethers`. | [`crypto-inventory/`](crypto-inventory/) | a cryptographic inventory of source code (JavaScript/TypeScript, Python, Java, Go, PEM blocks, dependency manifests): every use classified by its exposure to a quantum computer, with a migration target, written as a CycloneDX 1.6 CBOM; nothing from the scanned tree is executed, and its cost stays linear on input built to be slow | | [`verify-layer/`](verify-layer/) | an audit-log sidecar for any deployment: entries are AIP-23 envelopes in a hash chain, the runtime adapter records every running Docker or Kubernetes container without any secret value, and the head of the chain can be notarized on Aere Network for post-quantum finality; it says plainly what that proves (the history before a published head) and what it does not (that the host told the truth) | | [`proof-kinds/`](proof-kinds/) | the AIP-23 envelope builder the verification layer uses: fourteen proof kinds, one envelope format, digests instead of raw content | +| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass | +| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them. @@ -29,6 +31,8 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | crypto-inventory | 37/37 (`node test/proba.mjs`); cost on hostile input 8/8 linear (`node test/proba-timp.mjs`) | 18/18 (`node test/control-negativ.mjs`); cost 3/3 in this repository (`node test/control-negativ-timp.mjs`; its fourth case compares with version 0.1.0 from the development history and is skipped here) | | verify-layer | 34/34 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF= node proba-sidecar.mjs`); without it 30 run, 4 are reported as skipped and the exit code is 2 | 6/6 in this repository (`node control-negativ-sidecar.mjs`; its seventh case compares with the version from the development history and is skipped here) | | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test | +| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | +| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8 | remediation 7/7, compliance report 3/3 in this repository | Code comments, most function and variable names (also many exported between the files of a component), test names and control messages are in Romanian, and so are the two command words of the KMS HSM tool (explained in its README). Error codes, error @@ -37,4 +41,4 @@ in English. ## Licence -MIT, see [LICENSE](LICENSE). Files: 77 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3). +MIT, see [LICENSE](LICENSE). Files: 98 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, readiness 4). diff --git a/control-plane/README.md b/control-plane/README.md new file mode 100644 index 0000000..55af649 --- /dev/null +++ b/control-plane/README.md @@ -0,0 +1,97 @@ +# AERE Quantum Security Control Plane + +From "we found the problem" to "this is exactly what changes, in which order, what we can migrate automatically, how the rest is +fixed on your server, and how that is proven". Node.js 24 with OpenSSL 3.5, no dependencies. It composes tools that exist beside it: +the cryptographic inventory (`crypto-inventory/`), the TLS readiness scanner (`readiness/`), the PQ Gateway, KMS and PKI +(`pq-gateway/`, `pq-kms/`, `pq-pki/`), the verification layer (`verify-layer/`) and the AIP-23 envelope builder (`proof-kinds/`). + +Code comments, internal names, test names and file names are in Romanian; command lines, JSON fields, messages and this +documentation are in English (the data format was translated on 2026-09-29, version 2 of each format). + +## 1. Plan: `control-plane.mjs`, `plan-migrare.mjs` + + node control-plane.mjs --code [--scan scan.json] [--json] + +Runs the inventory on a code directory and, optionally, takes a scan of a hostname (the JSON of the readiness scanner), and writes a +prioritized plan. Every quantum-vulnerable asset becomes an action: + + { 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 } + +Harvest-now-decrypt-later applies to key exchange and encrypted data, **not to signatures** (a signature is public; it is not +harvested), so a classical key exchange on a public endpoint is the only CRITICAL case, and a classical signature is a future +forgery risk. The plan is honest about blockers: a public WebPKI certificate cannot be reissued post-quantum today (no public CA +issues one), so it is `blocked` with the reason written, not `auto`. The scan findings are classified by a closed table on the ids +the scanner actually emits (the test derives them from the scanner's source and fails on an id without a classification). + +## 2. Execution: `executa-migrare.mjs` + + 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) + +Nothing runs without consent **per action**, and the default is a dry run (`--execute` starts the work). Each `auto-aere` action is +done through the matching product and judged by a **measurement afterwards**, not by an exit code: + +| product | what it does | the proof afterwards | rollback | +|---|---|---|---| +| `gateway` | starts the PQ Gateway in front of the service and writes its configuration for your service manager | a client offering only `X25519MLKEM768` completes a TLS 1.3 handshake; a second, classical-only client says whether classical clients are still accepted (`hybrid-preferred`) or refused (`hybrid-only`) | the gateway is stopped | +| `kms` | creates the hybrid key (X25519 + ML-KEM-768) or rotates it if it exists; the name comes only from the `ref` (`mig-...`) | `latest_version` and the fingerprint read back from the KMS | older versions stay decryptable; nothing is deleted | +| `pki` | issues an ML-DSA-65 certificate from your PQ CA for a `cn` checked to be a host name (no wildcard) | `verifyChain` up to the root | files written in part are removed | + +Every action is a record in a hash chain `sha256(seq | prev | record)` in `execution.json` (`records[]`, each `{ seq, prev, record, +hash }`; verdicts `OK`, `FAILED`, `DRY-RUN`, `SKIPPED-no-consent`). The console and anyone else can check it again; a changed or +removed record is caught. It does not change your DNS, ports or firewall (moving traffic to the gateway stays with you), does not +issue public WebPKI certificates, and does not delete keys. + +## 3. Remediation: `remediere.mjs` + + node remediere.mjs recipe --plan plan.json --profile nginx|apache|haproxy|node|go [--json] + node remediere.mjs verify --plan plan.json --scan after.json [--applied-at 2026-09-28T10:00:00Z] [--out dir] + +The actions no AERE product can do (the `manual` ones: TLS 1.3 missing, TLS 1.2 accepted, the hybrid group not preferred, HSTS, the +certificate) live in your server's configuration, which we do not touch. The **recipe** gives, for your server software, the exact +fragment, where it goes, the measurable precondition (the OpenSSL or Go version and the command that shows it) and the configuration +check to run before reloading; fragments are generated from one structured form (groups, minimum version, HSTS), and certificate +problems get operational steps. The **proof**: after you apply it, a new scan judges each action `RESOLVED` (the defect is gone AND the +required property is measured), `UNRESOLVED`, or `UNMEASURED` (the scan failed, is of another host, or is not from after the time you +applied the change: `--applied-at`, otherwise the plan's `generatedAt`; with neither, everything is `UNMEASURED`). A domain in the +plan that is not a host name gets no recipe, so no command to copy is built with it. What is measured by us is written on every +recipe (`measured`): the `node` profile end to end; for nginx, Apache and HAProxy only that OpenSSL 3.5 accepts the group list (the +directive comes from their documentation); Go from the 1.24 release notes, not measured. + +## 4. Compliance report: `raport-conformitate.mjs` + + import { complianceReport, verifyReport } from './raport-conformitate.mjs'; + const { report, envelope, evidenceHash } = complianceReport({ plan, organization, date: '2026-09-28', risk: 'high', cnsa: true, cnsaCategories }); + +For every action: which regimes apply (NIST IR 8547 initial public draft; the EU coordinated roadmap of 2025-06-23; optionally +CNSA 2.0), each deadline and the days left on the report's date, and whether the asset is exposed today (key exchange: yes; +signature: no; a key of unknown use: not known). Each regime carries its source, the source's date and the date it was read; CNSA 2.0 +is read from secondary sources, and says so. An action the report cannot classify is counted, and then the report cannot say that +there is no vulnerable algorithm. The report is bound to an AIP-23 `compliance` envelope whose `evidenceHash` is the digest of the +canonical report. It is not a certification and not a legal opinion, and it covers only what was inventoried and scanned. + +## 5. Console: `consola.mjs`, `consola-web/` + + node consola.mjs --bundle bundle.json [--plan plan.json] [--execution execution.json] [--json] + +One state of a deployment: the audit chain of the verification layer recomputed from its entries, the integrity of every AIP-23 +envelope recomputed, the plan folded in, and the execution chain checked again. The console does not trust what a bundle says about +itself (`chainOk: true` over a modified log is reported as a suspect self-report). `consola-web/index.html` renders that output; it +computes nothing in the browser, and a bundle pasted there directly is shown as `UNCHECKED`. + +## Tests + + bash probele-b1.sh # all seven below, each VERDE / ROSU / STRICAT (in the development repository only) + node proba-plan-migrare.mjs # 30: real certificates and keys (openssl) through the real inventory; the scanner's ids derived from its source + node proba-control-plane.mjs # 9: the command line end to end, an all-post-quantum tree gives an empty plan + node proba-executa-migrare.mjs # 30: on real products started locally (KMS, PQ CA, gateway), with the attacks of the review + node proba-remediere.mjs # 33: real TLS servers on 127.0.0.1 scanned by the readiness scanner, before and after the recipe + node control-negativ-remediere.mjs # 7 of 7 guards broken in a copy, each turns the test red + node proba-raport-conformitate.mjs # 27: on the real outputs of the inventory and the scanner; the envelope through the AIP-23 verifier + node control-negativ-raport-conformitate.mjs # 3 plantings, each turns the named check red + node proba-consola.mjs # 8: a good bundle and four negative controls + +The report test runs the envelope through the AIP-23 reference verifier (`AERE_VERIFY_PROOF=`). +No third party has reviewed any of this. diff --git a/control-plane/consola-web/README.md b/control-plane/consola-web/README.md new file mode 100644 index 0000000..33e117f --- /dev/null +++ b/control-plane/consola-web/README.md @@ -0,0 +1,12 @@ +# Console viewer + +`index.html` renders the output of the control plane console (`node consola.mjs --bundle b.json --json`): the verdict, the +integrity of the audit chain, the integrity of the AIP-23 envelopes, the migration plan and the execution. It is a viewer, not a +second implementation of the checks: nothing is verified in the browser. The authority stays with `consola.mjs` and the +verification layer, and a sidecar bundle pasted directly is shown as `UNCHECKED`. + +The data shown on opening is an example generated by the tools (host `aws-eu-prod-1`, three entries). Paste your own output to +render it. The page loads the IBM Plex fonts from Google Fonts; everything else is in the file. + +Checked on 2026-09-29 by running its script in a minimal DOM (no browser): the example, a real console state (OK), a real broken +state (BROKEN) and a raw bundle (UNCHECKED) render without error. The layout was not measured in a real browser in that pass. diff --git a/control-plane/consola-web/index.html b/control-plane/consola-web/index.html new file mode 100644 index 0000000..a373140 --- /dev/null +++ b/control-plane/consola-web/index.html @@ -0,0 +1,341 @@ +AERE Verification Console + + + + + +
+
+
+ +
AERE Verification Console
quantum security control plane
+
+
+ host— + OK +
+
+
+ +
+

The state of a deployment, checked again independently by the control plane: the audit chain is recomputed from its entries, and the integrity of every AIP-23 envelope is recomputed. The console does not trust what the sidecar says about itself.

+ +
+ +
+ +
+

Audit chain

+
+
+ +
+

Post-quantum finality

+
+
+ +
+

Post-quantum migration

+
+
+
Migration plan
+
actions derived from the cryptographic inventory of the deployment
+
+
+ critical + automatic + manual +
+
+
+
+
+ +
+

Render your own output

+

Paste the output of node consola.mjs --bundle b.json --json or a bundle from node sidecar.mjs bundle.

+ +
+ + + +
+
+ +
+ A rendering of the output of the AUTHORITATIVE tools (consola.mjs and the sidecar). The verdict and the integrity of the chain and of the envelopes are computed by the tools, not in the browser: this page shows them as they are. The audit chain is an append-only hash chain where every entry covers sha256(seq | prev | envelope); its head can be given post-quantum finality by notarizing it on AereNotary (Aere Network). The envelopes follow the AERE Proof Protocol (AIP-23). The data shown on opening is an example deployment generated by the tools; the finality step shown is the one of that example (a head that can be notarized and is not yet). +
+
+ + diff --git a/control-plane/consola.mjs b/control-plane/consola.mjs new file mode 100644 index 0000000..a476e4d --- /dev/null +++ b/control-plane/consola.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +'use strict'; +// AERE Quantum Security Control Plane - CONSOLA (B1 milestone 3): "planul de control care le uneste". Ingera ce produc uneltele - un +// buraf de la sidecar-ul B3 (jurnal de audit + dovezi) si, optional, un plan de migrare de la control-plane si executia lui - si scoate +// O SINGURA stare verificabila a unei desfasurari. +// +// PRINCIPIUL, si e chiar valoarea: consola NU se increde in ce spune despre sine burafu. Recalculeaza ea insasi lantul de hash-uri +// (reia verificaJurnal din sidecar) si integritatea FIECARUI plic (sha256(JSON.stringify(statement)) == statementHash). Un host rau +// care preda un buraf cu `chainOk:true` peste un jurnal manipulat e prins aici. Finalitatea pe lant (notarizarea capului) e in afara +// consolei offline: sidecar-ul o da cu `verify-log --attested` si verificatorul AIP-23. Iesirea e in engleza (forma 2, 2026-09-29). +// +// node consola.mjs --bundle b.json [--plan plan.json] [--execution execution.json] [--json] +// iesire 0 = verdict OK (lant intreg SI toate plicurile integre); 1 = ceva stricat (BROKEN); 2 = nu s-a putut rula. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..'); +const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); + +// sidecar-ul: langa planul de control intr-o copie publica (../verify-layer), sau in depozitul de dezvoltare (tools/aere-verify-layer) +export function caleaSidecarului() { + const c = [path.join(AICI, '..', 'verify-layer', 'sidecar.mjs'), path.join(RAD, 'tools', 'aere-verify-layer', 'sidecar.mjs')]; + return c.find((p) => fs.existsSync(p)) || c[c.length - 1]; +} + +/** + * Reverifica un buraf de sidecar independent de ce declara el. + * @returns {object} starea consolei + */ +export async function evalueaza({ bundle, plan = null, execution = null }) { + const { verificaJurnal } = await import(pathToFileURL(caleaSidecarului()).href); + const intrari = Array.isArray(bundle.entries) ? bundle.entries : []; + // 1) lantul: recalculat, nu citit din bundle.chainOk + const lant = verificaJurnal(intrari); + // 2) integritatea fiecarui plic (tamper-evident, fara retea) + const plicuriRele = []; + for (const e of intrari) { + const p = e && e.proof; + const okForma = p && p.statement && typeof p.statementHash === 'string' && /^0x[0-9a-fA-F]{64}$/.test(p.statementHash); + const okInt = okForma && sha256(Buffer.from(JSON.stringify(p.statement), 'utf8')).toLowerCase() === p.statementHash.toLowerCase(); + if (!okInt) plicuriRele.push({ seq: e && e.seq, kind: p && (p.kind || (p.statement && p.statement.kind)) || '?', reason: okForma ? 'statementHash != sha256(statement)' : 'invalid envelope form' }); + } + // 3) minciuna auto-raportata: bundle.chainOk spune altceva decat masuratoarea noastra + const minciunaChainOk = typeof bundle.chainOk === 'boolean' && bundle.chainOk !== lant.ok; + // 4) planul de migrare (optional, informativ): forma planificatorului (actions/method/urgency). Un plan fara nicio lista + // recunoscuta e raportat ca atare, nu ca plan gol (2026-09-27: consola citea alte nume si afisa "0 actiuni" pe un plan real). + let migration = null; + if (plan) { + const items = Array.isArray(plan.actions) ? plan.actions : null; + migration = items === null + ? { total: null, error: 'the plan has no recognized list (actions)' } + : { + total: items.length, + critical: items.filter((i) => String(i.urgency || '').toUpperCase() === 'CRITICAL').length, + auto: items.filter((i) => i.method === 'auto-aere').length, + manual: items.filter((i) => i.method === 'manual').length, + blocked: items.filter((i) => i.method === 'blocked').length, + }; + } + // 5) executia migrarii (optional): lantul execution.json al executorului se RE-VERIFICA aici, nu se crede sumarul lui + let executie = null; + if (execution) { + const { verificaExecutie } = await import(pathToFileURL(path.join(AICI, 'executa-migrare.mjs')).href); + const v = verificaExecutie(execution); + const recs = (execution.records || []).map((e) => e && e.record).filter((r) => r && r.ref); + executie = { chainOk: v.ok, brokenAtSeq: v.ok ? null : v.seq, reason: v.ok ? null : v.reason, actions: recs.length, + ok: recs.filter((r) => r.verdict === 'OK').length, failed: recs.filter((r) => r.verdict === 'FAILED').length }; + } + const okTot = lant.ok && plicuriRele.length === 0 && !minciunaChainOk && (executie === null || executie.chainOk) && !(migration && migration.total === null); + return { + v: 2, kind: 'aere-control-plane-console', + host: bundle.host || '?', + auditChain: { ok: lant.ok, count: lant.count, head: lant.head, brokenAtSeq: lant.rupt, reason: lant.motiv || null }, + envelopes: { total: intrari.length, intact: intrari.length - plicuriRele.length, bad: plicuriRele }, + selfReportSuspect: minciunaChainOk ? `the bundle declares chainOk=${bundle.chainOk} but the measurement gives ${lant.ok}` : null, + migration, + execution: executie, + verdict: okTot ? 'OK' : 'BROKEN', + }; +} + +async function main() { + const args = process.argv.slice(2); + const get = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; }; + const bf = get('--bundle'); if (!bf) { console.error('usage: node consola.mjs --bundle b.json [--plan plan.json] [--execution execution.json] [--json]'); return 2; } + const bundle = JSON.parse(fs.readFileSync(bf, 'utf8')); + const pf = get('--plan'); const plan = pf ? JSON.parse(fs.readFileSync(pf, 'utf8')) : null; + const xf = get('--execution'); const execution = xf ? JSON.parse(fs.readFileSync(xf, 'utf8')) : null; + const st = await evalueaza({ bundle, plan, execution }); + if (args.includes('--json')) console.log(JSON.stringify(st, null, 1)); + else { + console.log(`CONTROL PLANE CONSOLE - host ${st.host}: ${st.verdict}`); + console.log(` audit chain: ${st.auditChain.ok ? 'INTACT' : 'BROKEN at seq ' + st.auditChain.brokenAtSeq}, ${st.auditChain.count} entries, head ${st.auditChain.head || '-'}`); + console.log(` AIP-23 envelopes: ${st.envelopes.intact}/${st.envelopes.total} intact` + (st.envelopes.bad.length ? ` (bad: ${st.envelopes.bad.map((x) => x.seq).join(',')})` : '')); + if (st.selfReportSuspect) console.log(` WARNING: ${st.selfReportSuspect}`); + if (st.migration) console.log(st.migration.total === null ? ` PQ migration: ${st.migration.error}` : ` PQ migration: ${st.migration.total} actions (${st.migration.critical} critical, ${st.migration.auto} auto, ${st.migration.manual} manual, ${st.migration.blocked} blocked)`); + if (st.execution) console.log(` execution: chain ${st.execution.chainOk ? 'INTACT' : 'BROKEN at seq ' + st.execution.brokenAtSeq + ' (' + st.execution.reason + ')'}, ${st.execution.actions} actions (${st.execution.ok} OK, ${st.execution.failed} FAILED)`); + } + return st.verdict === 'OK' ? 0 : 1; +} +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().then((c) => { process.exitCode = c; }).catch((e) => { console.error(e.message); process.exitCode = 2; }); +} diff --git a/control-plane/control-negativ-raport-conformitate.mjs b/control-plane/control-negativ-raport-conformitate.mjs new file mode 100644 index 0000000..96f4eca --- /dev/null +++ b/control-plane/control-negativ-raport-conformitate.mjs @@ -0,0 +1,63 @@ +// control-negativ-raport-conformitate.mjs (2026-09-29): controlul negativ al verificarilor RC1-RC3 din proba-raport-conformitate.mjs +// (revizuirea adversariala dinaintea publicarii). Trei stari pe caz: PRINS (proba a ajuns la capat si verificarea numita e rosie), +// SCAPAT, STRICAT (copia nu s-a incarcat sau proba nu a ajuns la capat). +// V0 raportul de dinainte de reparatie, din git (sarit si spus daca revizia nu e in istoricul depozitului) -> RC1, RC2, RC3 rosii +// P1 RC1 scos cu o conditie falsa la rulare -> RC1 rosie +// P2 RC2 scos -> RC2 rosie +// P3 RC3 scos (cheile luate si din prototip) -> RC3 rosie +// Copiile stau LANGA original (importurile relative trebuie sa se rezolve) si se sterg; originalul trebuie sa ramana octet cu octet. +// node control-negativ-raport-conformitate.mjs -> 0 toate prinse, 1 unul scapa, 2 STRICAT +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const SRC = path.join(AICI, 'raport-conformitate.mjs'); +const PROBA = path.join(AICI, 'proba-raport-conformitate.mjs'); +const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'); +const inainte = sha(SRC); +const REV = process.env.AERE_RAPORT_REV_VECHE || 'a88be275'; +let prinse = 0, stricate = 0, total = 0; + +function caz(id, text, tinte) { + total++; + const copie = path.join(AICI, `.plantat-raport-${id}.mjs`); + try { + fs.writeFileSync(copie, text); + const r = spawnSync(process.execPath, [PROBA], { encoding: 'utf8', timeout: 280000, env: { ...process.env, AERE_RAPORT_MODUL: pathToFileURL(copie).href } }); + const out = (r.stdout || '') + (r.stderr || ''); + if (!/raportul de conformitate: \d+\/\d+ cum trebuia/.test(out)) { stricate++; console.log(` STRICAT ${id}: proba nu a ajuns la capat (${out.trim().split('\n').filter((l) => !/^\s+at /.test(l)).pop()?.slice(0, 120)})`); return; } + const rosii = out.split('\n').filter((l) => l.includes('[RAU ]')); + const lipsa = tinte.filter((x) => !rosii.some((l) => l.includes(x))); + if (r.status === 1 && !lipsa.length) { prinse++; console.log(` PRINS ${id}: ${rosii.length} verificari rosii, intre ele ${tinte.join(', ')}`); } + else console.log(` SCAPAT ${id}: cod ${r.status}, nerosii: ${lipsa.join(', ') || '-'}`); + } finally { fs.rmSync(copie, { force: true }); } +} +const planta = (t, a, b) => (t.split(a).length === 2 ? t.replace(a, b) : null); + +const CALE = `${REV}:cloud-gateway/control-plane/raport-conformitate.mjs`; +if (spawnSync('git', ['cat-file', '-e', CALE], { cwd: AICI }).status !== 0) console.log(` SARIT V0: revizia ${REV} nu e in istoricul acestui depozit; NEMASURAT aici`); +else { + const g = spawnSync('git', ['-c', 'core.autocrlf=false', 'show', CALE], { cwd: AICI, encoding: 'utf8' }); + if (g.status !== 0 || g.stdout.includes('RC1 (2026-09-29)')) { stricate++; total++; console.log(' STRICAT V0: revizia veche nu se citeste sau are deja reparatia'); } + // forma 1 a raportului (API si campuri in romana, pana la 2026-09-29) nu se poate masura cu proba formei 2: nimic comparabil + else if (g.stdout.includes('organizatie')) console.log(` SARIT V0: revizia ${REV} e forma 1 a raportului (API romanesc); proba formei 2 nu o poate masura. RC1-RC3 raman pazite de P1-P3`); + else caz('V0', g.stdout, ['RC1:', 'RC2:', 'RC3:']); +} +const nou = fs.readFileSync(SRC, 'utf8'); +const P = [ + ['P1', 'rezumat.verdict = vulnerabile === 0 && rezumat.unclassified > 0', "rezumat.verdict = vulnerabile === 0 && rezumat.unclassified > 0 && process.env.AERE_PLANTA_NICIODATA === 'da'", ['RC1:']], + ['P2', "if (Number.isNaN(zi0.getTime()) || zi0.toISOString().slice(0, 10) !== date) throw", "if (Number.isNaN(zi0.getTime()) && process.env.AERE_PLANTA_NICIODATA === 'da') throw", ['RC2:']], + ['P3', "const cc = typeof cat === 'string' && Object.hasOwn(C.categories, cat) ? C.categories[cat] : null;", 'const cc = cat ? C.categories[cat] : null;', ['RC3:']], +]; +for (const [id, a, b, tinte] of P) { + const t = planta(nou, a, b); + if (!t) { stricate++; total++; console.log(` STRICAT ${id}: ancora nu apare exact o data`); continue; } + caz(id, t, tinte); +} +const ramase = fs.readdirSync(AICI).filter((f) => f.startsWith('.plantat-raport-')); +if (sha(SRC) !== inainte || ramase.length) { stricate++; console.log(' STRICAT originalul s-a schimbat sau au ramas copii: ' + ramase.join(',')); } +console.log(`\ncontrolul negativ RC1-RC3: prinse ${prinse}/${total}, stricate ${stricate}`); +process.exitCode = stricate ? 2 : prinse === total ? 0 : 1; diff --git a/control-plane/control-negativ-remediere.mjs b/control-plane/control-negativ-remediere.mjs new file mode 100644 index 0000000..59fd167 --- /dev/null +++ b/control-plane/control-negativ-remediere.mjs @@ -0,0 +1,54 @@ +// Controlul negativ al probei remedierii: fiecare paznic din remediere.mjs e stricat intr-o COPIE a arborelui, si proba trebuie sa +// iasa ROSIE pe copia stricata (cu probele chiar rulate: se cere cel putin un rand RAU, nu doar codul de iesire), iar pe copia +// neatinsa VERDE. O plantare care nu se potriveste cu sursa (tiparul nu mai exista) e un ESEC al controlului, nu o linie informativa. +// node control-negativ-remediere.mjs iesire 0 = copia neatinsa verde si toate plantarile rosii +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +// dosarul scanerului: readiness/ intr-o copie publica, cloud-gateway/ in depozitul de dezvoltare (aceeasi regula ca in control-plane.mjs) +const { caleaScanerului } = await import('./control-plane.mjs'); +const CG = path.dirname(caleaScanerului()); +const PLANTARI = [ + { nume: 'judecata fara proprietatea pozitiva ("defect absent" = rezolvat)', din: 'if (inca || poz === false)', in: 'if (inca)' }, + { nume: 'reteta node fara grupul hibrid', din: 'if (s.groups) opt.ecdhCurve = grup(s.groups);', in: 'if (false) opt.ecdhCurve = grup(s.groups);' }, + { nume: 'scanarea de dinainte de aplicare acceptata', din: 'else if (!(Date.parse(scan.measuredAt) > Date.parse(limita)))', in: 'else if (false)' }, + // 2026-09-29, R1 si R2 (revizuirea adversariala inainte de publicare); R2 pune inapoi FORMA VECHE intreaga (fara margine = se judeca) + { nume: 'R1: domeniul din ref folosit fara verificare in comenzi', din: 'if (d && Object.hasOwn(REMEDIERI, d.id) && !domeniuValid(d.domain))', in: 'if (d && Object.hasOwn(REMEDIERI, d.id) && !domeniuValid(d.domain) && process.env.AERE_PLANTA_NICIODATA)' }, + { nume: 'R2: fara margine de timp se judeca pe orice scanare', perechi: [ + ['const limita = o.appliedAt || plan.generatedAt || null;', 'const limita = o.appliedAt || null;'], + ["else if (!limita || !Number.isFinite(Date.parse(limita)))", 'else if (false)'], + ['else if (!(Date.parse(scan.measuredAt) > Date.parse(limita)))', 'else if (limita && !(Date.parse(scan.measuredAt) > Date.parse(limita)))'] ] }, + { nume: 'alta gazda acceptata', din: 'else if (scan.domain !== d.domain)', in: 'else if (false)' }, + { nume: 'versiunea minima scapata din unire', din: "if (x.tlsMin) s.tlsMin = s.tlsMin === '1.3' || x.tlsMin === '1.3' ? '1.3' : '1.2';", in: "if (x.tlsMin) s.tlsMin = '1.2';" }, +]; +function copie() { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-b1-rem-cn-')); + fs.mkdirSync(path.join(t, 'control-plane')); + for (const f of ['readiness-service.mjs', 'adrese-private.mjs']) fs.copyFileSync(path.join(CG, f), path.join(t, f)); + for (const f of ['remediere.mjs', 'proba-remediere.mjs', 'plan-migrare.mjs', 'executa-migrare.mjs', 'control-plane.mjs']) fs.copyFileSync(path.join(AICI, f), path.join(t, 'control-plane', f)); + return t; +} +function ruleaza(t) { + const r = spawnSync(process.execPath, [path.join(t, 'control-plane', 'proba-remediere.mjs')], { encoding: 'utf8', timeout: 240000 }); + const out = (r.stdout || '') + (r.stderr || ''); + return { cod: r.status, rele: (out.match(/^\s+RAU\s/gm) || []).length, bune: (out.match(/^\s+OK\s/gm) || []).length, out }; +} +let esecuri = 0; +const t0 = copie(); const r0 = ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true }); +if (r0.cod === 0 && r0.rele === 0 && r0.bune > 0) console.log(` OK copia neatinsa: verde (${r0.bune} treceri)`); +else { esecuri++; console.log(` RAU copia neatinsa nu e verde (cod ${r0.cod}, ${r0.rele} RAU)`); } +for (const p of PLANTARI) { + const t = copie(); const f = path.join(t, 'control-plane', 'remediere.mjs'); const src = fs.readFileSync(f, 'utf8'); + const perechi = p.perechi || [[p.din, p.in]]; + if (perechi.some(([a]) => src.split(a).length !== 2)) { esecuri++; console.log(` RAU ${p.nume}: tiparul nu apare exact o data in sursa (plantarea nu s-a putut pune)`); fs.rmSync(t, { recursive: true, force: true }); continue; } + fs.writeFileSync(f, perechi.reduce((s, [a, b]) => s.replace(a, b), src)); + const r = ruleaza(t); fs.rmSync(t, { recursive: true, force: true }); + if (r.cod !== 0 && r.rele > 0 && r.bune > 0) console.log(` OK ${p.nume}: proba ROSIE (${r.rele} RAU)`); + else { esecuri++; console.log(` RAU ${p.nume}: proba nu a iesit rosie din motivul plantat (cod ${r.cod}, ${r.rele} RAU, ${r.bune} OK)`); } +} +console.log(esecuri ? `RAU: ${esecuri} esecuri ale controlului` : `DOVEDIT: copia neatinsa verde, ${PLANTARI.length} din ${PLANTARI.length} plantari rosii`); +process.exitCode = esecuri ? 1 : 0; diff --git a/control-plane/control-plane.mjs b/control-plane/control-plane.mjs new file mode 100644 index 0000000..621fd25 --- /dev/null +++ b/control-plane/control-plane.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +'use strict'; +// AERE Quantum Security Control Plane (B1), CLI cap la cap: inventar criptografic + (optional) scanare de pregatire PQ -> plan de +// migrare prioritizat. Compune uneltele care exista deja - nu reinventeaza: inventarul (crypto-inventory), scanerul (dat ca JSON), +// planificatorul (plan-migrare.mjs). Ruleaza offline pe un dosar de cod; scanarea unui hostname se da aici cu --scan . +// Iesirea e in engleza (forma planului 2, vezi plan-migrare.mjs). +// +// node control-plane.mjs --code [--scan scan.json] [--json] +// iesire 0 = plan produs (chiar si gol); 2 = nu s-a putut rula. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..'); + +// inventarul: langa planul de control intr-o copie publica (../crypto-inventory), sau in depozitul de dezvoltare (tools/) +export function caleaInventarului() { + const c = [path.join(AICI, '..', 'crypto-inventory', 'inventar.mjs'), path.join(RAD, 'tools', 'crypto-inventory', 'inventar.mjs')]; + return c.find((p) => fs.existsSync(p)) || c[c.length - 1]; +} + +// scanerul de pregatire PQ: in dosarul readiness/ al unei copii publice, sau langa planul de control (depozitul de dezvoltare) +export function caleaScanerului() { + const c = [path.join(AICI, '..', 'readiness', 'readiness-service.mjs'), path.join(AICI, '..', 'readiness-service.mjs')]; + return c.find((p) => fs.existsSync(p)) || c[c.length - 1]; +} + +// adaptor: findings-ul inventarului e DEJA clasificat (are assetType, name, primitive, quantumVulnerable, certificate in engleza); +// nu se re-cheama classify. Se adauga doar ref (unic pe fisier:linie) si locatia. +function laPlanificator(g) { + return { + ref: `crypto:${g.assetType || 'algorithm'}:${g.name || g.primitive || 'unknown'}:${g.file || ''}:${g.line || ''}`, + assetType: g.assetType || 'algorithm', + name: g.name, + // primitiva inventarului trece NEATINSA: pentru certificate ea e 'unknown'/'other' (a cheii), iar ce inseamna asta pentru un + // certificat decide planificatorul (primitivaDe), intr-un singur loc + primitive: g.primitive, + quantumVulnerable: g.quantumVulnerable, + curve: g.curve, parameterSetIdentifier: g.parameterSetIdentifier, + certificate: g.certificate, + material: g.material, + location: g.file ? `${g.file}:${g.line || '?'}` : undefined, + }; +} + +async function main() { + const args = process.argv.slice(2); + const jsonOut = args.includes('--json'); + const codeI = args.indexOf('--code'); const code = codeI >= 0 ? args[codeI + 1] : null; + const scanI = args.indexOf('--scan'); const scanFile = scanI >= 0 ? args[scanI + 1] : null; + if (!code) { console.error('usage: node control-plane.mjs --code [--scan scan.json] [--json]'); process.exit(2); } + + const inv = await import(pathToFileURL(caleaInventarului()).href); + const { planeaza } = await import(pathToFileURL(path.join(AICI, 'plan-migrare.mjs')).href); + + let rez; try { rez = inv.scan(code); } catch (e) { console.error('the inventory could not scan: ' + e.message); process.exit(2); } + const clasificate = (rez.findings || []).map(laPlanificator); + + let scan = null; + if (scanFile) { + try { scan = JSON.parse(fs.readFileSync(scanFile, 'utf8')); } catch (e) { console.error('cannot read the scan: ' + e.message); process.exit(2); } + } + + const plan = planeaza({ inventory: clasificate, scan }); + const iesire = { + generatedAt: new Date().toISOString(), + code, scan: scanFile || null, + inventory: { findings: (rez.findings || []).length, quantumVulnerable: clasificate.filter((c) => c.quantumVulnerable).length }, + ...plan, + }; + + if (jsonOut) { console.log(JSON.stringify(iesire, null, 1)); process.exit(0); } + console.log(`AERE Control Plane - post-quantum migration plan for ${code}${scanFile ? ' + ' + scanFile : ''}`); + console.log(` inventory: ${iesire.inventory.findings} uses, ${iesire.inventory.quantumVulnerable} quantum-vulnerable`); + console.log(` plan: ${plan.summary.total} actions | ${JSON.stringify(plan.summary.byUrgency)} | auto=${plan.summary.autoAere} manual=${plan.summary.manual} blocked=${plan.summary.blocked}`); + for (const a of plan.actions) { + console.log(` [${a.urgency.padEnd(8)}] ${a.asset}${a.location ? ' (' + a.location + ')' : ''}`); + console.log(` ${a.problem} -> ${a.target} [${a.method}${a.product ? ' via ' + a.product : ''}]${a.blocker ? ' BLOCKED BY: ' + a.blocker : ''}`); + } + process.exit(0); +} +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((e) => { console.error('error: ' + (e.stack || e.message)); process.exit(2); }); +} diff --git a/control-plane/executa-migrare.mjs b/control-plane/executa-migrare.mjs new file mode 100644 index 0000000..5c006b4 --- /dev/null +++ b/control-plane/executa-migrare.mjs @@ -0,0 +1,251 @@ +// 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; } +} diff --git a/control-plane/plan-migrare.mjs b/control-plane/plan-migrare.mjs new file mode 100644 index 0000000..5b25d03 --- /dev/null +++ b/control-plane/plan-migrare.mjs @@ -0,0 +1,194 @@ +'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 }; diff --git a/control-plane/proba-consola.mjs b/control-plane/proba-consola.mjs new file mode 100644 index 0000000..d342cfb --- /dev/null +++ b/control-plane/proba-consola.mjs @@ -0,0 +1,79 @@ +'use strict'; +// Proba consolei (B1 m3): un buraf bun -> verdict OK; si controale NEGATIVE care arata ca CONSOLA NU SE INCREDE in buraf: +// (1) buraf care declara chainOk:true peste un jurnal manipulat -> consola prinde minciuna (lant RUPT + autoRaportSuspect); +// (2) buraf cu lant INTERN COERENT (hash-uri refacute) dar cu un plic al carui statementHash minte -> consola prinde plicul. +// plus: planul de migrare se pliaza in stare. +// node proba-consola.mjs -> 0 toate cum trebuia, 1 altfel + +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { evalueaza, caleaSidecarului } from './consola.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..'); +const SIDE = caleaSidecarului(); +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'cons-')); +let rele = 0; const cer = (n, c) => { console.log(` [${c ? 'OK ' : 'RAU '}] ${n}`); if (!c) rele++; }; +const side = (args) => { try { execFileSync(process.execPath, [SIDE, ...args], { encoding: 'utf8' }); return 0; } catch (e) { return e.status ?? 1; } }; + +try { + const { hashIntrare } = await import(pathToFileURL(SIDE).href); + const log = path.join(T, 'audit.log'); + const bin = path.join(T, 'node'); fs.writeFileSync(bin, 'BIN'); + const sh = '0x' + crypto.createHash('sha256').update(fs.readFileSync(bin)).digest('hex'); + side(['record', '--kind', 'runtime', '--artifact', bin, '--attested', sh, '--host', 'h1', '--log', log, '--at', '2026-09-26T09:00:00Z']); + side(['record', '--kind', 'deployment', '--name', 'app', '--version', '1.0', '--content-file', bin, '--log', log, '--at', '2026-09-26T09:01:00Z']); + const bf = path.join(T, 'b.json'); side(['bundle', '--log', log, '--out', bf, '--host', 'h1', '--at', '2026-09-26T09:02:00Z']); + const bundle = JSON.parse(fs.readFileSync(bf, 'utf8')); + + const okSt = await evalueaza({ bundle }); + cer('buraf bun -> verdict OK', okSt.verdict === 'OK' && okSt.auditChain.ok && okSt.envelopes.intact === okSt.envelopes.total); + + // CONTROL 1: minciuna auto-raportata + jurnal manipulat (hash stale) + const b1 = JSON.parse(JSON.stringify(bundle)); b1.entries[0].proof.statement.host = 'ATACATOR'; b1.chainOk = true; + const st1 = await evalueaza({ bundle: b1 }); + cer('CONTROL 1: chainOk:true peste jurnal manipulat -> STRICAT (lant rupt)', st1.verdict === 'BROKEN' && st1.auditChain.ok === false); + cer('CONTROL 1: consola marcheaza minciuna auto-raportata', !!st1.selfReportSuspect); + + // CONTROL 2: lant INTERN COERENT (hash-uri refacute) dar plic cu statementHash mincinos + const b2 = JSON.parse(JSON.stringify(bundle)); + b2.entries[0].proof.statement.host = 'ATACATOR'; // statementHash ramane cel vechi -> minte + // refac lantul ca sa fie intern coerent (asa cum ar face un host rau destept) + let prev = '0x' + '00'.repeat(32); + for (const e of b2.entries) { e.prev = prev; e.hash = hashIntrare(e.seq, e.prev, e.proof); prev = e.hash; } + b2.chainOk = true; b2.head = prev; + const st2 = await evalueaza({ bundle: b2 }); + cer('CONTROL 2: lant coerent dar plic mincinos -> lantul trece, PLICUL cade', st2.auditChain.ok === true && st2.envelopes.bad.length === 1 && st2.verdict === 'BROKEN'); + + // planul de migrare se pliaza. 2026-09-27: planul vine din PLANIFICATORUL real, nu dintr-o fixtura scrisa de mana; fixtura veche + // (`items`/`mod`) era chiar forma presupusa de consola, deci proba masura presupunerea, iar pe un plan real consola afisa 0 actiuni. + const { planeaza } = await import(pathToFileURL(path.join(AICI, 'plan-migrare.mjs')).href); + const inv = [ + { assetType: 'algorithm', name: 'ECDH', primitive: 'key-agree', quantumVulnerable: true, ref: 'a:1' }, + { assetType: 'algorithm', name: 'RSA-2048', primitive: 'signature', quantumVulnerable: true, ref: 'a:2' }, + { assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false, ref: 'a:3' }, + ]; + const plan = planeaza({ inventory: inv, scan: { domain: 'exemplu.test', findings: [{ id: 'hndl-exposed', severity: 'high', title: 'No post-quantum key exchange' }] } }); + const nAuto = plan.actions.filter((a) => a.method === 'auto-aere').length; + const nCrit = plan.actions.filter((a) => a.urgency === 'CRITICAL').length; + const st3 = await evalueaza({ bundle, plan }); + cer(`planul REAL al planificatorului se pliaza in stare (${plan.actions.length} actiuni, ${nCrit} critice, ${nAuto} auto)`, + st3.migration && st3.migration.total === plan.actions.length && plan.actions.length > 0 && st3.migration.auto === nAuto && st3.migration.critical === nCrit && nAuto > 0 && nCrit > 0); + // CONTROL 3: un plan fara nicio lista recunoscuta nu e "plan gol cu 0 actiuni": consola o spune si verdictul e STRICAT + const st4 = await evalueaza({ bundle, plan: { ceva: [1, 2] } }); + cer('CONTROL 3: plan fara lista recunoscuta -> nu "0 actiuni", ci eroare numita si STRICAT', st4.migration && st4.migration.total === null && st4.verdict === 'BROKEN'); + + // executia migrarii: consola re-verifica lantul executie.json al executorului (aici in mod USCAT, fara produse) + const { executa } = await import(pathToFileURL(path.join(AICI, 'executa-migrare.mjs')).href); + const outX = path.join(T, 'exec'); const x = await executa(plan, { consent: 'all', execute: false, out: outX }); + const execution = JSON.parse(fs.readFileSync(x.file, 'utf8')); + const st5 = await evalueaza({ bundle, plan, execution }); + cer(`executia (uscata) se pliaza: lant intreg, ${st5.execution && st5.execution.actions} actiuni`, st5.execution && st5.execution.chainOk && st5.execution.actions === nAuto && st5.verdict === 'OK'); + // CONTROL 4: o inregistrare de executie schimbata -> consola o prinde si verdictul e STRICAT + const ex2 = JSON.parse(JSON.stringify(execution)); ex2.records[1].record.verdict = 'OK-fals'; + const st6 = await evalueaza({ bundle, plan, execution: ex2 }); + cer('CONTROL 4: inregistrare de executie schimbata -> lant RUPT, STRICAT', st6.execution && !st6.execution.chainOk && st6.execution.brokenAtSeq === 1 && st6.verdict === 'BROKEN'); +} finally { fs.rmSync(T, { recursive: true, force: true }); } +const total = 8; +console.log(`\nB1 m3 consola: ${total - rele}/${total} cum trebuia (buraf bun + 4 controale negative + plan real + executie)`); +process.exitCode = rele ? 1 : 0; diff --git a/control-plane/proba-control-plane.mjs b/control-plane/proba-control-plane.mjs new file mode 100644 index 0000000..1f49785 --- /dev/null +++ b/control-plane/proba-control-plane.mjs @@ -0,0 +1,59 @@ +'use strict'; +// Proba CLI-ului Control Plane cap la cap (B1 milestone 2), cu CONTROL NEGATIV: un dosar cu cod vulnerabil produce actiunile +// corecte; un dosar tot-PQ produce plan gol. Isi face singura fixturile intr-un temp si le sterge. +// node proba-control-plane.mjs -> 0 toate cum trebuia, 1 altfel + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(AICI, 'control-plane.mjs'); +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-proba-')); +let rele = 0; +const cer = (n, c) => { console.log(` [${c ? 'OK ' : 'RAU '}] ${n}`); if (!c) rele++; }; + +function ruleaza(dir, scanFile) { + const args = [CLI, '--code', dir, '--json']; + if (scanFile) args.push('--scan', scanFile); + return JSON.parse(execFileSync(process.execPath, args, { encoding: 'utf8' })); +} + +try { + // fixtura vulnerabila + const v = path.join(T, 'vuln'); fs.mkdirSync(v); + fs.writeFileSync(path.join(v, 'a.js'), "const c=require('crypto');\nc.createECDH('prime256v1');\nc.createSign('RSA-SHA256');\n"); + // fixtura PQ + const pq = path.join(T, 'pq'); fs.mkdirSync(pq); + fs.writeFileSync(path.join(pq, 'b.js'), "// ML-KEM-768 si ML-DSA-65, doar nume\nconst a='ML-KEM-768', s='ML-DSA-65';\n"); + // scanare de proba (readiness): un schimb de cheie clasic pe un host + const scanF = path.join(T, 'scan.json'); + fs.writeFileSync(scanF, JSON.stringify({ domain: 'client.test', findings: [{ id: 'hndl-exposed', severity: 'high', title: 'No post-quantum key exchange: harvest-now-decrypt-later exposure' }] })); + + const pv = ruleaza(v); + const byRef = Object.fromEntries(pv.actions.map((a) => [a.ref.split(':').slice(0, 3).join(':'), a])); + cer('dosar vulnerabil: 2 folosiri vulnerabile', pv.inventory.quantumVulnerable === 2); + cer('dosar vulnerabil: 2 actiuni in plan', pv.summary.total === 2); + const ecdh = pv.actions.find((a) => /ECDH/i.test(a.name || a.asset)); + const rsa = pv.actions.find((a) => /RSA/i.test(a.name || a.asset)); + cer('ECDH -> gateway (schimb de cheie, auto)', ecdh && ecdh.product === 'gateway' && ecdh.method === 'auto-aere'); + cer('RSA -> pki (semnatura, auto)', rsa && rsa.product === 'pki'); + cer('HNDL: ECDH mai urgent decat RSA', ecdh && rsa && ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[ecdh.urgency] < { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[rsa.urgency])); + cer('fiecare actiune are locatia (fisier:linie)', pv.actions.every((a) => a.location && /\.js:\d/.test(a.location))); + + // cu scanare: apare si actiunea de schimb de cheie TLS (gateway, CRITICA) + const pvs = ruleaza(v, scanF); + const kex = pvs.actions.find((a) => /tls-kex/.test(a.ref)); + cer('cu scanare: schimbul de cheie TLS e in plan, gateway CRITICA', kex && kex.product === 'gateway' && kex.urgency === 'CRITICAL'); + + // CONTROL NEGATIV: dosar tot-PQ -> 0 vulnerabile, plan gol + const ppq = ruleaza(pq); + cer('CONTROL: dosar tot-PQ -> 0 vulnerabile', ppq.inventory.quantumVulnerable === 0); + cer('CONTROL: dosar tot-PQ -> plan gol', ppq.summary.total === 0); +} finally { + fs.rmSync(T, { recursive: true, force: true }); +} +console.log(`\nB1 control-plane CLI: ${9 - rele}/9 cum trebuia`); +process.exit(rele ? 1 : 0); diff --git a/control-plane/proba-executa-migrare.mjs b/control-plane/proba-executa-migrare.mjs new file mode 100644 index 0000000..22d0dee --- /dev/null +++ b/control-plane/proba-executa-migrare.mjs @@ -0,0 +1,211 @@ +// Proba executorului B1 (executa-migrare.mjs), pe produse REALE pornite local: un KMS (pq-kms/server.mjs cu cheie-radacina si jeton +// generate aici, scrise in fisiere 0600, niciodata tiparite), un CA PQ (pq-pki/cli.mjs init + intermediate, parola din mediu), un gateway +// PQ pornit de executor in fata unui upstream HTTP local, si un certificat clasic de test pentru gateway (openssl). Fara retea externa. +// Forma 2 a inregistrarilor (2026-09-29, in engleza): records/record, steps/step, before/after, verdict OK|FAILED|DRY-RUN|SKIPPED-no-consent. +// Cazuri (fiecare cu perechea lui): +// 1. fara consimtamant + execute=true -> nimic executat: aceleasi chei in KMS, niciun certificat scris, niciun gateway pornit +// 2. consimtamant 'all' + dry run -> la fel, nimic atins; inregistrarile spun DRY-RUN +// 3. consimtamant 'all' + execute -> gateway OK (hybrid-only: un client numai-PQ trece, unul numai-CLASIC e refuzat, masurat), +// KMS OK (cheie hibrida, latest_version 1), PKI OK (ML-DSA-65 pana la radacina); execution.json ok +// 4. a doua executie -> KMS ROTESTE (latest_version 2), min_decryption_version pastreaza versiunea veche +// 5. plantare: cheia gateway-ului NU e a certificatului -> actiunea gateway FAILED (gateway-ul refuza sa porneasca), CELELALTE merg +// 6. plantare: o inregistrare din execution.json schimbata -> verificaExecutie o prinde; nemodificata -> trece (controlul metodei) +// 7. actiunile 'manual'/'blocked' nu se executa niciodata (notExecutable in sumar) +// Atacurile revizuirii adversariale (2026-09-27), fiecare reprodus pe codul vechi inainte de reparatie: +// 8. produs 'constructor'/'toString' (mostenit din prototip) -> FAILED, nu OK fara actiune +// 9. gateway pe hybrid-preferred -> OK, iar inregistrarea spune ca un client clasic e inca acceptat +// 10. un camp `key` din plan care numeste o cheie straina existenta -> cheia straina NU e rotita +// 11. modul uscat scrie tinta efectiva; 12. un cn wildcard -> FAILED, niciun fisier scris +// Ref-urile REALE ale planificatorului (control-plane.mjs --code pe un dosar de cod generat aici), 2026-09-27: +// 13. doua actiuni KMS din acelasi fisier lung (liniile 4 si 7) -> DOUA chei distincte; consimtamantul pentru una nu o roteste pe cealalta +// 14. o actiune PKI fara cn, cu un ref real lung -> CN-ul implicit e un nume de gazda valid si certificatul se emite +// Numele fisierelor se citesc din INREGISTRARI (ce a raportat executorul), nu se ghicesc. +// node proba-executa-migrare.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { executa, verificaExecutie } from './executa-migrare.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const KMS_SERVER = path.join(AICI, '..', 'pq-kms', 'server.mjs'); +const PKI_CLI = path.join(AICI, '..', 'pq-pki', 'cli.mjs'); +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-b1-exec-')); +const copii = []; +let up = null; +const dormi = (ms) => new Promise((r) => setTimeout(r, ms)); +const rezultate = []; let ok = 0, rau = 0; +const fisiere = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f !== 'execution.json') : []); +const pas = (r, cheie) => (r && r.steps ? (r.steps.find((s) => s[cheie]) || {})[cheie] : undefined); +const inreg = (x) => x.records.map((i) => i.record); +function cere(cond, ce) { rezultate.push((cond ? 'OK ' : 'RAU ') + ce); if (cond) ok++; else rau++; } +function alnum(n) { const a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; return Array.from(crypto.randomBytes(n)).map((b) => a[b % a.length]).join(''); } +function ruleaza(cmd, args, env, ms = 60000) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); + let o = '', e = ''; p.stdout.on('data', (b) => { o += b; }); p.stderr.on('data', (b) => { e += b; }); + const t = setTimeout(() => p.kill(), ms); + p.on('close', (cod) => { clearTimeout(t); resolve({ cod, o, e }); }); + p.on('error', (err) => { clearTimeout(t); resolve({ cod: -1, o, e: String(err.message) }); }); + }); +} +async function openssl(args) { + let r = await ruleaza('openssl', args, {}); + if (r.cod === -1 && /ENOENT/.test(r.e)) r = await ruleaza('C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', args, {}); + if (r.cod !== 0) throw new Error('openssl a cazut: ' + r.e.slice(0, 200)); +} +async function kmsGet(url, token, cale) { const r = await fetch(url + cale, { headers: { authorization: 'Bearer ' + token } }); let j = null; try { j = await r.json(); } catch {} return { status: r.status, j }; } + +try { + // --- KMS local + const kmsPort = 18420 + crypto.randomInt(1000); const kmsUrl = `http://127.0.0.1:${kmsPort}`; + const kmsToken = alnum(40); const tokenFile = path.join(T, 'kms.token'); fs.writeFileSync(tokenFile, kmsToken + '\n', { mode: 0o600 }); + fs.mkdirSync(path.join(T, 'kms'), { recursive: true, mode: 0o700 }); + const kms = spawn(process.execPath, [KMS_SERVER], { env: { ...process.env, AERE_KMS_ROOT_KEY: crypto.randomBytes(32).toString('hex'), AERE_KMS_TOKEN: kmsToken, AERE_KMS_PORT: String(kmsPort), AERE_KMS_HOST: '127.0.0.1', AERE_KMS_DATA_DIR: path.join(T, 'kms') }, stdio: ['ignore', 'pipe', 'pipe'] }); + copii.push(kms); let kmsErr = ''; kms.stderr.on('data', (b) => { kmsErr += b; }); + let sus = false; for (let i = 0; i < 100 && !sus; i++) { try { const r = await fetch(kmsUrl + '/v1/health'); sus = r.status < 500; } catch { await dormi(100); } } + if (!sus) throw new Error('KMS-ul local nu a pornit: ' + kmsErr.slice(0, 200)); + // --- CA PQ local (parola numai in mediul copiilor si in optiuni, niciodata tiparita) + const parola = alnum(26); const ca = path.join(T, 'ca'); + let r = await ruleaza(process.execPath, [PKI_CLI, 'init', '--dir', ca, '--name', 'root', '--org', 'Proba'], { AERE_PKI_PASSPHRASE: parola }); + if (r.cod !== 0) throw new Error('pki init: ' + (r.e || r.o).slice(0, 200)); + r = await ruleaza(process.execPath, [PKI_CLI, 'intermediate', '--dir', ca, '--ca', 'root', '--name', 'issuing'], { AERE_PKI_PASSPHRASE: parola }); + if (r.cod !== 0) throw new Error('pki intermediate: ' + (r.e || r.o).slice(0, 200)); + // --- certificat clasic de test pentru gateway (ce are clientul azi) + o a doua pereche pentru plantarea "cheia nu e a certificatului" + await openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(T, 'gw.key'), '-out', path.join(T, 'gw.crt'), '-subj', '/CN=localhost', '-days', '2', '-addext', 'subjectAltName=DNS:localhost']); + await openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(T, 'alt.key'), '-out', path.join(T, 'alt.crt'), '-subj', '/CN=localhost', '-days', '2']); + // --- upstream HTTP local + up = http.createServer((q, s) => s.end('ok')); await new Promise((r2) => up.listen(0, '127.0.0.1', r2)); const upPort = up.address().port; + // --- planul (forma 2 a lui plan-migrare) + const plan = { actions: [ + { ref: 'tls-kex:localhost', asset: 'TLS on localhost', target: 'X25519MLKEM768 (hybrid)', method: 'auto-aere', product: 'gateway', urgency: 'CRITICAL', how: 'PQ Gateway in front' }, + { ref: 'kem:app-secret', asset: 'the application envelope', target: 'ML-KEM-768 (hybrid with X25519)', method: 'auto-aere', product: 'kms', urgency: 'CRITICAL', how: 'hybrid envelope through the KMS' }, + { ref: 'sig:svc.internal', asset: 'the service identity', target: 'ML-DSA-65', method: 'auto-aere', product: 'pki', urgency: 'HIGH', cn: 'svc.internal', how: 'PQ certificate' }, + { ref: 'tls-cert:public.example', asset: 'public certificate', target: 'ML-DSA-65', method: 'blocked', product: null, urgency: 'MEDIUM', blocker: 'public CA without post-quantum' }, + ] }; + const gw = (extra = {}) => ({ cert: path.join(T, 'gw.crt'), key: path.join(T, 'gw.key'), upstream: `http://127.0.0.1:${upPort}`, mode: 'hybrid-only', selfSigned: true, servername: 'localhost', keep: false, ...extra }); + const baza = (out, extra = {}) => ({ out: path.join(T, out), gateway: gw(), kms: { url: kmsUrl, token: kmsToken }, pki: { dir: ca, ca: 'issuing', roots: path.join(ca, 'root.crt'), passphrase: parola }, ...extra }); + const chei = async () => { const l = await kmsGet(kmsUrl, kmsToken, '/v1/keys'); return (l.j && l.j.keys ? l.j.keys.length : -1); }; + const chei0 = await chei(); + + // 1. fara consimtamant + let x = await executa(plan, baza('e1', { consent: [], execute: true })); + cere(x.summary.skipped === 3 && x.summary.ok === 0 && x.summary.notExecutable === 1, `1. fara consimtamant: 3 sarite, 0 executate, 1 neexecutabila (${JSON.stringify(x.summary)})`); + cere((await chei()) === chei0 && fisiere(path.join(T, 'e1')).length === 0, `1. fara consimtamant: KMS neatins, niciun fisier scris (${fisiere(path.join(T, 'e1')).join(',') || 'niciunul'})`); + // 2. uscat + x = await executa(plan, baza('e2', { consent: 'all', execute: false })); + cere(x.summary.dryRun === 3 && x.summary.ok === 0, `2. uscat: 3 DRY-RUN, 0 executate (${JSON.stringify(x.summary)})`); + cere((await chei()) === chei0 && fisiere(path.join(T, 'e2')).length === 0, `2. uscat: KMS neatins, nimic scris (${fisiere(path.join(T, 'e2')).join(',') || 'niciun fisier'})`); + // 3. executat + x = await executa(plan, baza('e3', { consent: 'all', execute: true })); + const rec = (ref) => inreg(x).find((i) => i.ref === ref); + const g = rec('tls-kex:localhost'), k = rec('kem:app-secret'), p = rec('sig:svc.internal'); + cere(x.summary.ok === 3 && x.summary.failed === 0, `3. executat: 3 OK (${JSON.stringify(x.summary)})`); + cere(g && g.verdict === 'OK' && g.steps.some((s) => /TLS 1\.3 test/.test(s.step) && s.ok) && pas(g, 'file') && fs.existsSync(pas(g, 'file')), `3. gateway (hybrid-only): un client numai-PQ termina strangerea, configuratie emisa`); + cere(g && g.after && g.after.classicalAccepted === false, `3. gateway hybrid-only: un client numai-CLASIC e REFUZAT, masurat (${g && g.after ? g.after.guarantee : '?'})`); + cere(k && k.verdict === 'OK' && k.after && k.after.latest_version === 1 && (await chei()) === chei0 + 1, `3. kms: cheie hibrida creata, latest_version=${k && k.after ? k.after.latest_version : '?'}`); + cere(p && p.verdict === 'OK' && p.steps.some((s) => /chain verified/.test(s.step) && s.ok) && pas(p, 'cert') && fs.existsSync(pas(p, 'cert')) && fs.existsSync(pas(p, 'key')), `3. pki: certificat ${p && p.after ? p.after.alg : '?'} emis si verificat pana la radacina (${p && p.after ? p.after.chain.join(' <- ') : '?'})`); + const v3 = verificaExecutie(JSON.parse(fs.readFileSync(x.file, 'utf8'))); + cere(v3.ok && v3.seq === x.records.length, `3. execution.json: lantul de hash-uri se verifica (${v3.seq} inregistrari)`); + // 4. a doua executie: KMS roteste + x = await executa(plan, baza('e4', { consent: ['kem:app-secret'], execute: true })); + const k2 = inreg(x).find((i) => i.ref === 'kem:app-secret'); + cere(k2 && k2.verdict === 'OK' && k2.before && k2.before.exists && k2.after.latest_version === 2 && (await chei()) === chei0 + 1, `4. a doua executie: cheia ROTITA (latest_version=${k2 && k2.after ? k2.after.latest_version : '?'}), nu creata a doua oara`); + cere(k2 && k2.after && k2.after.min_decryption_version !== null && k2.after.min_decryption_version <= 1, `4. intoarcere: versiunea veche ramane decriptabila (min_decryption_version=${k2 && k2.after ? k2.after.min_decryption_version : '?'})`); + // 5. plantare: cheia nu e a certificatului -> gateway FAILED, restul OK + x = await executa(plan, baza('e5', { consent: 'all', execute: true, gateway: gw({ key: path.join(T, 'alt.key') }) })); + const g5 = inreg(x).find((i) => i.ref === 'tls-kex:localhost'); + cere(g5 && g5.verdict === 'FAILED' && x.summary.failed === 1 && x.summary.ok === 2, `5. plantare (cheia nu e a certificatului): gateway FAILED (${g5 ? g5.error : '?'}), celelalte 2 OK`); + cere(!fisiere(path.join(T, 'e5')).some((f) => f.endsWith('.gateway.env')) && fisiere(path.join(T, 'e5')).some((f) => f.endsWith('.crt')), '5. plantare: nicio configuratie de gateway emisa pentru actiunea cazuta (certificatul actiunii PKI, da)'); + // 6. tamper pe execution.json + const dosar = JSON.parse(fs.readFileSync(path.join(T, 'e3', 'execution.json'), 'utf8')); + cere(verificaExecutie(dosar).ok, '6. controlul metodei: dosarul nemodificat se verifica'); + const t2 = JSON.parse(JSON.stringify(dosar)); const iK = t2.records.findIndex((i) => i.record.ref === 'kem:app-secret'); t2.records[iK].record.verdict = 'OK-fals'; + const vt = verificaExecutie(t2); + cere(!vt.ok && vt.seq === iK, `6. plantare: o inregistrare schimbata e prinsa la seq ${vt.seq} (${vt.reason})`); + const t3 = JSON.parse(JSON.stringify(dosar)); t3.records.splice(iK, 1); t3.records.forEach((e, i) => { e.seq = i; }); + cere(!verificaExecutie(t3).ok, '6. plantare: o inregistrare STEARSA e prinsa (lantul nu mai leaga)'); + // 7. neexecutabile + cere(x.summary.notExecutable === 1 && !x.records.some((i) => i.record.ref === 'tls-cert:public.example'), '7. actiunea blocata (CA public fara PQ) nu e nici macar incercata'); + + // --- atacurile revizuirii adversariale (2026-09-27), fiecare reprodus inainte de reparatie --- + // 8. produs mostenit din Object.prototype: inainte, `constructor`/`toString` ieseau OK fara nicio actiune + for (const numeProt of ['constructor', 'toString']) { + const xp = await executa({ actions: [{ ref: 'prot:' + numeProt, method: 'auto-aere', product: numeProt }] }, baza('e8-' + numeProt, { consent: 'all', execute: true })); + const rp = inreg(xp).find((i) => i.ref === 'prot:' + numeProt); + cere(xp.summary.ok === 0 && xp.summary.failed === 1 && rp.verdict === 'FAILED', `8. ATAC: produs '${numeProt}' (mostenit din prototip) -> FAILED, nu OK (${rp.error})`); + } + // 9. gateway pe modul implicit hybrid-preferred: OK, dar inregistrarea spune ONEST ca un client clasic e inca acceptat + x = await executa({ actions: [plan.actions[0]] }, baza('e9', { consent: 'all', execute: true, gateway: gw({ mode: 'hybrid-preferred' }) })); + const g9 = inreg(x).find((i) => i.ref === 'tls-kex:localhost'); + cere(g9 && g9.verdict === 'OK' && g9.after.classicalAccepted === true && /hybrid-only/.test(g9.after.guarantee), `9. hybrid-preferred: OK, iar inregistrarea spune ca un client clasic e inca acceptat (${g9 && g9.after && g9.after.guarantee ? g9.after.guarantee.slice(0, 70) : '?'})`); + // 10. un camp `key` din plan care numeste o cheie STRAINA existenta nu o mai roteste (numele vine numai din ref) + const strain = 'cheie-straina-prod'; + let rs = await fetch(`${kmsUrl}/v1/keys/${strain}`, { method: 'POST', headers: { authorization: 'Bearer ' + kmsToken, 'content-type': 'application/json' }, body: JSON.stringify({ type: 'encrypt' }) }); + if (rs.status === 404 || rs.status === 405) rs = await fetch(`${kmsUrl}/v1/keys`, { method: 'POST', headers: { authorization: 'Bearer ' + kmsToken, 'content-type': 'application/json' }, body: JSON.stringify({ name: strain, type: 'encrypt' }) }); + const vStrain0 = (await kmsGet(kmsUrl, kmsToken, `/v1/keys/${strain}`)).j?.latest_version; + x = await executa({ actions: [{ ...plan.actions[1], ref: 'kem:alta', key: strain }] }, baza('e10', { consent: 'all', execute: true })); + const vStrain1 = (await kmsGet(kmsUrl, kmsToken, `/v1/keys/${strain}`)).j?.latest_version; + const k10 = inreg(x).find((i) => i.ref === 'kem:alta'); + cere(vStrain0 === 1 && vStrain1 === 1 && k10 && k10.after && k10.after.key !== strain && /^mig-kem-alta/.test(k10.after.key), `10. ATAC: cheie straina numita in plan NU e rotita (versiunea ei ${vStrain0} -> ${vStrain1}); s-a lucrat pe ${k10 && k10.after ? k10.after.key : '?'}`); + // 11. modul uscat arata TINTA efectiva, ca omul sa consimta la ce se atinge de fapt + x = await executa({ actions: [{ ...plan.actions[1], ref: 'kem:vizibil', key: strain }] }, baza('e11', { consent: 'all', execute: false })); + const u11 = inreg(x).find((i) => i.ref === 'kem:vizibil'); + cere(u11 && u11.verdict === 'DRY-RUN' && /mig-kem-vizibil/.test(u11.effectiveTarget) && u11.steps.some((s) => /mig-kem-vizibil/.test(s.step)), `11. uscat: tinta efectiva e scrisa (${u11 ? u11.effectiveTarget : '?'})`); + // 12. PKI: un cn wildcard e refuzat INAINTE de emitere, si nu ramane niciun fisier scris + x = await executa({ actions: [{ ...plan.actions[2], ref: 'sig:wild', cn: '*.bank.example' }] }, baza('e12', { consent: 'all', execute: true })); + const p12 = inreg(x).find((i) => i.ref === 'sig:wild'); + cere(p12 && p12.verdict === 'FAILED' && fisiere(path.join(T, 'e12')).length === 0, `12. ATAC: cn wildcard refuzat, niciun fisier scris (${p12 ? p12.error : '?'})`); + + // --- ref-urile REALE ale planificatorului: control-plane.mjs pe un dosar de cod generat aici --- + const cod = path.join(T, 'cod'); + fs.mkdirSync(path.join(cod, 'services', 'payments', 'settlement'), { recursive: true }); + fs.mkdirSync(path.join(cod, 'services', 'auth', 'tokens'), { recursive: true }); + fs.writeFileSync(path.join(cod, 'services', 'payments', 'settlement', 'envelope.js'), [ + "const crypto = require('node:crypto');", "const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });", + 'function wrap(k) {', ' return crypto.publicEncrypt(publicKey, k);', '}', 'function wrapForAudit(k, auditKey) {', + ' return crypto.publicEncrypt(auditKey, k);', '}', 'module.exports = { wrap, wrapForAudit };', ''].join('\n')); + fs.writeFileSync(path.join(cod, 'services', 'auth', 'tokens', 'session.js'), [ + "const crypto = require('node:crypto');", "const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });", + 'function signSession(data) {', " return crypto.sign('sha256', data, privateKey);", '}', "const h = crypto.createSign('RSA-SHA256');", + 'module.exports = { signSession, h };', ''].join('\n')); + const cp = await ruleaza(process.execPath, [path.join(AICI, 'control-plane.mjs'), '--code', cod, '--json'], {}); + if (cp.cod !== 0) throw new Error('control-plane.mjs a cazut: ' + (cp.e || cp.o).slice(0, 200)); + const planReal = JSON.parse(cp.o); + const kmsReal = planReal.actions.filter((a) => a.method === 'auto-aere' && a.product === 'kms' && /envelope\.js:/.test(a.ref)); + const pkiReal = planReal.actions.filter((a) => a.method === 'auto-aere' && a.product === 'pki' && /session\.js:/.test(a.ref) && !a.cn); + // controlul fixturii: planificatorul REAL chiar a dat cazurile pe care le masuram, altfel 13/14 nu inseamna nimic + cere(kmsReal.length === 2 && pkiReal.length >= 1, `13/14. controlul fixturii: planificatorul real da ${kmsReal.length} actiuni KMS in envelope.js si ${pkiReal.length} PKI fara cn in session.js (${kmsReal.map((a) => a.ref.split(':').slice(-1)).join(',')})`); + // 13. doua chei distincte, iar consimtamantul pentru una nu o roteste pe cealalta + const c13 = await chei(); + x = await executa({ actions: kmsReal }, baza('e13', { consent: 'all', execute: true })); + const r13 = kmsReal.map((a) => inreg(x).find((i) => i.ref === a.ref)); + const n13 = r13.map((r) => (r && r.after ? r.after.key : null)); + const noi13 = (await chei()) - c13; + cere(x.summary.ok === 2 && n13[0] && n13[1] && n13[0] !== n13[1] && noi13 === 2, `13. doua ref-uri reale din acelasi fisier -> doua chei DISTINCTE create (${n13.join(' / ')}; chei noi in KMS: ${noi13})`); + x = await executa({ actions: kmsReal }, baza('e13b', { consent: [kmsReal[0].ref], execute: true })); + const v13 = []; + for (const nume of n13) v13.push(nume ? (await kmsGet(kmsUrl, kmsToken, `/v1/keys/${encodeURIComponent(nume)}`)).j?.latest_version : null); + cere(v13[0] === 2 && v13[1] === 1, `13. consimtamant numai pentru ${kmsReal[0].ref.split(':').slice(-2).join(':')} -> cheia lui rotita (v${v13[0]}), a celuilalt NEATINSA (v${v13[1]})`); + cere(n13.every((nm) => nm && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(nm)), `13. numele sunt nume KMS valide (${n13.map((nm) => (nm ? nm.length : 0)).join(', ')} caractere)`); + // 14. PKI fara cn: CN-ul implicit e un nume de gazda valid, certificatul se emite, si fiecare actiune isi scrie fisierele ei + x = await executa({ actions: pkiReal }, baza('e14', { consent: 'all', execute: true })); + const r14 = pkiReal.map((a) => inreg(x).find((i) => i.ref === a.ref)); + cere(r14.every((r) => r && r.verdict === 'OK' && r.after && /\.internal$/.test(r.after.cn)), `14. PKI pe ref-uri reale fara cn: ${r14.map((r) => (r ? r.verdict + ' ' + (r.after ? r.after.cn : r.error) : '?')).join(' | ')}`); + const certs14 = r14.map((r) => pas(r, 'cert')); + cere(certs14.every(Boolean) && new Set(certs14).size === certs14.length && certs14.every((f) => fs.existsSync(f)), `14. fiecare actiune PKI isi are fisierele ei (${certs14.length} certificate, ${new Set(certs14).size} distincte)`); +} catch (e) { + rezultate.push('RAU proba nu a putut rula: ' + String(e.message).replace(/[A-Za-z0-9+/=]{48,}/g, '…').slice(0, 300)); rau++; +} finally { + for (const c of copii) { try { c.kill(); } catch {} } + // upstream-ul local tinea bucla vie: proba tiparea verdictul si nu mai iesea (rularile din fundal nu se terminau niciodata) + if (up) { try { up.closeAllConnections(); up.close(); } catch {} } +} +for (const l of rezultate) console.log(l); +if (!rau) { try { fs.rmSync(T, { recursive: true, force: true }); } catch {} } +console.log(`B1 executor: ${ok}/${ok + rau} cum trebuia`); +if (rau) console.log('dosarul de proba a ramas pentru cercetare: ' + T); +process.exitCode = rau ? 1 : 0; diff --git a/control-plane/proba-plan-migrare.mjs b/control-plane/proba-plan-migrare.mjs new file mode 100644 index 0000000..319c749 --- /dev/null +++ b/control-plane/proba-plan-migrare.mjs @@ -0,0 +1,131 @@ +'use strict'; +// Proba planificatorului de migrare (B1), cu CONTROALE NEGATIVE: un asset vulnerabil TREBUIE sa apara in plan cu tinta si metoda +// corecte; un asset deja post-cuantic NU trebuie sa apara; HNDL (schimb de cheie) trebuie sa fie mai urgent decat o semnatura. +// node proba-plan-migrare.mjs -> 0 toate cum trebuia, 1 altfel + +import { planeaza } from './plan-migrare.mjs'; + +let rele = 0; let total = 0; +function cer(nume, cond) { total++; console.log(` [${cond ? 'OK ' : 'RAU '}] ${nume}`); if (!cond) rele++; } + +// fixtura de inventar: doua vulnerabile (schimb de cheie + semnatura de certificat) + unul deja PQ (nu trebuie in plan) +const inventory = [ + { ref: 'algorithm:ecdh', assetType: 'algorithm', name: 'ECDH', primitive: 'key-agree', quantumVulnerable: true }, + { ref: 'algorithm:rsa', assetType: 'algorithm', name: 'RSA-2048', primitive: 'signature', quantumVulnerable: true }, + { ref: 'cert:interna', assetType: 'certificate', primitive: 'signature', quantumVulnerable: true, + certificate: { subjectName: 'CN=api.intern', issuerName: 'CN=Intern CA', notValidAfter: '2030-01-01T00:00:00Z' } }, + { ref: 'cert:webpki', assetType: 'certificate', primitive: 'signature', quantumVulnerable: true, + certificate: { subjectName: 'CN=www.exemplu.com', issuerName: "C=US, O=Let's Encrypt, CN=R3", notValidAfter: '2026-12-01T00:00:00Z' } }, + { ref: 'algorithm:mlkem', assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false }, + { ref: 'algorithm:mldsa', assetType: 'algorithm', name: 'ML-DSA-65', primitive: 'signature', quantumVulnerable: false }, + { ref: 'algorithm:aes', assetType: 'algorithm', name: 'AES-256-GCM', primitive: 'ae', quantumVulnerable: false }, +]; +// 2026-09-27: constatarile de scanare se DERIVA din sursa scanerului (readiness-service.mjs, apelurile lui add(id, severitate, +// titlu)), nu se scriu de mana. Fixtura veche folosea id-uri inventate ('tls-kex-classical', 'tls12-enabled'), deci proba era verde +// pe un clasificator care, pe id-urile reale, arunca `tls13-missing` si facea din `pq-not-preferred`/`auth-classical` expuneri CRITICE. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const { caleaScanerului } = await import('./control-plane.mjs'); +const SRC_SCANER = fs.readFileSync(caleaScanerului(), 'utf8'); +function constatariDinScaner(src) { + const re = /add\('([a-z0-9-]+)',\s*'([a-z]+)',\s*[`']([^`']+)[`']/g; const f = []; let m; + while ((m = re.exec(src))) f.push({ id: m[1], severity: m[2], title: m[3] }); + return f; +} +const REALE = constatariDinScaner(SRC_SCANER); +const dupaId = (id) => REALE.find((x) => x.id === id); +const scan = { domain: 'client.exemplu.com', findings: [dupaId('hndl-exposed'), dupaId('tls12-accepted')].filter(Boolean) }; + +const plan = planeaza({ inventory, scan }); +const byRef = Object.fromEntries(plan.actions.map((a) => [a.ref, a])); +console.log('plan:', plan.summary.total, 'actiuni;', JSON.stringify(plan.summary.byUrgency), 'auto=' + plan.summary.autoAere, 'manual=' + plan.summary.manual, 'blocked=' + plan.summary.blocked); + +// pozitive +cer('ECDH (schimb de cheie) e in plan, auto prin gateway', byRef['algorithm:ecdh'] && byRef['algorithm:ecdh'].product === 'gateway'); +cer('RSA (semnatura) e in plan, tinta ML-DSA prin PKI', byRef['algorithm:rsa'] && /ML-DSA/.test(byRef['algorithm:rsa'].target) && byRef['algorithm:rsa'].product === 'pki'); +cer('certificatul intern e in plan, auto prin PKI', byRef['cert:interna'] && byRef['cert:interna'].product === 'pki'); +cer('certificatul WebPKI public e BLOCAT (niciun CA public PQ), nu se pretinde auto', byRef['cert:webpki'] && byRef['cert:webpki'].method === 'blocked' && byRef['cert:webpki'].blocker); +cer('scanarea: schimbul de cheie clasic e in plan, auto prin gateway', byRef['tls-kex:client.exemplu.com'] && byRef['tls-kex:client.exemplu.com'].product === 'gateway'); +cer('scanarea: TLS 1.2 e in plan, manual (config)', byRef['tls-conf:client.exemplu.com:tls12-accepted'] && byRef['tls-conf:client.exemplu.com:tls12-accepted'].method === 'manual'); + +// controale negative: ce NU trebuie sa fie in plan +cer('CONTROL: ML-KEM (deja PQ) NU e in plan', !byRef['algorithm:mlkem']); +cer('CONTROL: ML-DSA (deja PQ) NU e in plan', !byRef['algorithm:mldsa']); +cer('CONTROL: AES-256 (nu e cu risc cuantic de spart) NU e in plan', !byRef['algorithm:aes']); + +// HNDL: schimbul de cheie e mai urgent decat semnatura (memorie: HNDL nu se aplica semnaturilor) +const uKex = byRef['tls-kex:client.exemplu.com'].urgency, uSig = byRef['algorithm:rsa'].urgency; +const rang = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; +cer(`HNDL: schimbul de cheie (${uKex}) e mai urgent decat semnatura (${uSig})`, rang[uKex] < rang[uSig]); + +// planul e sortat pe urgenta (prima actiune e cea mai urgenta) +cer('planul e sortat: prima actiune e CRITICA', plan.actions[0] && plan.actions[0].urgency === 'CRITICAL'); + +// CONTROL NEGATIV al metodei: un plan pe un inventar CURAT (totul PQ) e GOL +const planCurat = planeaza({ inventory: [{ ref: 'a', assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false }] }); +cer('CONTROL: inventar tot-PQ -> plan gol', planCurat.actions.length === 0); + +// CLASIFICAREA PE CONTRACTUL REAL AL SCANERULUI: fiecare id din sursa scanerului e in tabel, si semantica lui e cea corecta +const CLASIFICARE_SCAN = (await import('./plan-migrare.mjs')).CLASIFICARE_SCAN || {}; // modul fara tabel -> nimic acoperit (rosu masurat, nu cadere) +const distincte = [...new Set(REALE.map((x) => x.id))]; +cer(`scanerul emite ${distincte.length} id-uri distincte, extrase din sursa (control pozitiv al extragerii: >= 10)`, distincte.length >= 10); +const neacoperite = distincte.filter((i) => !Object.hasOwn(CLASIFICARE_SCAN, i)); +cer(`fiecare id al scanerului are o clasificare scrisa (neacoperite: ${neacoperite.join(',') || 'niciunul'})`, neacoperite.length === 0); +// CONTROL NEGATIV al acoperirii: un id nou, adaugat in scaner fara clasificare, TREBUIE sa iasa neacoperit +const cuNou = [...distincte, 'pq-ceva-nou']; +cer('CONTROL: un id nou al scanerului, neclasificat, e prins de verificarea acoperirii', cuNou.some((i) => !Object.hasOwn(CLASIFICARE_SCAN, i))); +const unul = (id) => { const f = dupaId(id); const pl = planeaza({ scan: { domain: 'd.test', findings: [f] } }); return pl.actions[0] || null; }; +cer('numai expunerea HNDL e CRITICA si automata (gateway)', unul('hndl-exposed') && unul('hndl-exposed').urgency === 'CRITICAL' && unul('hndl-exposed').product === 'gateway'); +cer('PQ deja suportat dar nepreferat NU e CRITIC (ordinea grupurilor, MEDIE, manual)', unul('pq-not-preferred') && unul('pq-not-preferred').urgency === 'MEDIUM' && unul('pq-not-preferred').method === 'manual'); +cer('TLS 1.3 lipsa produce o actiune INALTA (inainte era aruncata)', unul('tls13-missing') && unul('tls13-missing').urgency === 'HIGH'); +cer('informativul pq-preference-unmeasured nu devine actiune', unul('pq-preference-unmeasured') === null); +cer('autentificarea clasica e BLOCATA onest (CA public fara PQ), nu CRITICA', unul('auth-classical') && unul('auth-classical').method === 'blocked' && unul('auth-classical').urgency !== 'CRITICAL'); +cer('expirarea certificatului e o actiune de EXPIRARE, nu de semnatura clasica', unul('cert-expiring') && /tls-cert-expiry/.test(unul('cert-expiring').ref)); +cer('lantul neincrezut produce o actiune (inainte era aruncat)', unul('chain-untrusted') !== null); +// in toate formele reale, o singura actiune e CRITICA: cea HNDL +const critice = distincte.map((i) => unul(i)).filter((a) => a && a.urgency === 'CRITICAL'); +cer(`pe toate cele ${distincte.length} forme reale, exact o actiune CRITICA (HNDL): ${critice.map((a) => a.ref).join(',')}`, critice.length === 1 && /^tls-kex:/.test(critice[0].ref)); + +// CERTIFICATELE PE FORMA REALA (2026-09-27): certificate si chei generate cu openssl, inventarul REAL si control-plane.mjs --json pe ele. +// Fixtura de sus scrie `primitive: 'signature'` pe certificate; inventarul real scrie primitiva CHEII ('unknown' la RSA, 'other' la EC), +// si pe forma aceea regula veche a planificatorului nu se aplica niciodata. Aici se masoara exact forma aceea. +{ + const os = await import('node:os'); + const { spawnSync } = await import('node:child_process'); + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-plan-cert-')); + const D = path.join(T, 'deploy', 'tls'); fs.mkdirSync(D, { recursive: true }); + const openssl = (args) => { + let r = spawnSync('openssl', args, { encoding: 'utf8' }); + if (r.error && r.error.code === 'ENOENT') r = spawnSync('C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', args, { encoding: 'utf8' }); + return !r.error && r.status === 0; + }; + const facut = openssl(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(D, 'api.key'), '-out', path.join(D, 'api.crt'), '-subj', '/O=Client/CN=api.internal', '-days', '800']) + && openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(D, 'edge.key'), '-out', path.join(D, 'edge.crt'), '-subj', '/CN=edge.internal', '-days', '30']) + && openssl(['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', path.join(D, 'wild.key'), '-out', path.join(D, 'wild.crt'), '-subj', '/CN=*.svc.internal', '-days', '400']); + cer('certificate reale: openssl a generat cele trei certificate si chei (fara el nimic de mai jos nu masoara)', facut); + const cp = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'control-plane.mjs'), '--code', T, '--json'], { encoding: 'utf8' }); + let pr = null; try { pr = JSON.parse(cp.stdout); } catch { pr = null; } + cer(`certificate reale: control-plane.mjs --json a rulat (cod ${cp.status})`, cp.status === 0 && pr && Array.isArray(pr.actions)); + const { caleaInventarului } = await import('./control-plane.mjs'); + const inv = await import((await import('node:url')).pathToFileURL(caleaInventarului()).href); + const certInv = (inv.scan(T).findings || []).filter((g) => g.assetType === 'certificate'); + // controlul fixturii: inventarul real chiar da primitive NEDECISE pe certificate; daca intr-o zi da 'signature', cazul de fata nu mai + // masoara regula veche, si trebuie stiut + cer(`certificate reale: controlul fixturii, inventarul da ${certInv.length} certificate cu primitivele ${[...new Set(certInv.map((g) => g.primitive))].join(',')}`, + certInv.length === 3 && certInv.every((g) => g.primitive === 'unknown' || g.primitive === 'other')); + const act = (fis) => (pr && pr.actions ? pr.actions.find((a) => new RegExp(`:deploy/tls/${fis.replace('.', '\\.')}:`).test(a.ref)) : null); + const api = act('api.crt'), edge = act('edge.crt'), wild = act('wild.crt'), cheie = act('api.key'); + cer(`certificatul RSA intern (800 de zile) -> auto prin PKI, ML-DSA, INALTA, pentru acelasi CN (${api ? [api.method, api.product, api.urgency, api.cn].join(' ') : 'lipsa'})`, + api && api.method === 'auto-aere' && api.product === 'pki' && /ML-DSA/.test(api.target) && api.urgency === 'HIGH' && api.cn === 'api.internal'); + cer(`certificatul EC intern (30 de zile) -> auto prin PKI, MEDIE (${edge ? [edge.method, edge.product, edge.urgency, edge.cn].join(' ') : 'lipsa'})`, + edge && edge.method === 'auto-aere' && edge.product === 'pki' && edge.urgency === 'MEDIUM' && edge.cn === 'edge.internal'); + cer(`certificatul wildcard -> manual, cu motivul scris, fara CN inventat (${wild ? [wild.method, (wild.blocker || '').slice(0, 40)].join(' | ') : 'lipsa'})`, + wild && wild.method === 'manual' && !wild.product && /wildcard/.test(wild.blocker || '') && !wild.cn); + cer(`cheia privata -> manual, numita ca cheie privata, cu motivul (${cheie ? cheie.asset + ' | ' + (cheie.how || '').slice(0, 50) : 'lipsa'})`, + cheie && cheie.method === 'manual' && /private-key/.test(cheie.asset) && /private key/.test(cheie.how || '')); + try { fs.rmSync(T, { recursive: true, force: true }); } catch {} +} + +console.log(`\nB1 planificator: ${total - rele}/${total} cum trebuia`); +process.exitCode = rele ? 1 : 0; diff --git a/control-plane/proba-raport-conformitate.mjs b/control-plane/proba-raport-conformitate.mjs new file mode 100644 index 0000000..2bbb895 --- /dev/null +++ b/control-plane/proba-raport-conformitate.mjs @@ -0,0 +1,122 @@ +// Proba raportului de conformitate post-cuantica (raport-conformitate.mjs), pe iesirile REALE ale producatorilor, nu pe o fixtura +// scrisa in forma presupusa (lectia din 27 sept: consola, planificatorul si verificatorul tradus au iesit verzi pe forme inventate): +// - inventarul: crypto-inventory rulat prin control-plane.mjs --json pe un dosar temporar cu un certificat RSA facut de openssl si un +// fisier de cod care cere ECDH si o semnatura RSA; +// - scanarea: constatari cu id-urile, severitatile si titlurile EXTRASE din sursa scanerului (readiness-service.mjs, apelurile add()). +// Plus plicul AIP-23 trecut prin verificatorul de referinta publicat, si controale negative (raport atins, inventar numai PQ, risc). +// node cloud-gateway/control-plane/proba-raport-conformitate.mjs -> 0 toate cum trebuia, 1 altfel, 2 NEMASURAT (producator nepornit) +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +// AERE_RAPORT_MODUL: numai pentru controlul negativ (control-negativ-raport-conformitate.mjs: o copie plantata, langa original) +const { raportConformitate, verificaRaport, natura, sha256Canonic } = await import(process.env.AERE_RAPORT_MODUL || './raport-conformitate.mjs'); +import { planeaza } from './plan-migrare.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..'); +// verificatorul AIP-23: AERE_VERIFY_PROOF, sau cel din depozitul de dezvoltare +const VERIFY = process.env.AERE_VERIFY_PROOF ? path.resolve(process.env.AERE_VERIFY_PROOF) : path.join(RAD, 'verificator-falcon', 'verify-proof.mjs'); +if (!fs.existsSync(VERIFY)) { console.log(`NEMASURAT: verificatorul AIP-23 nu e la ${VERIFY}; dati AERE_VERIFY_PROOF (de ex. verify-proof.mjs din aere-node/tools)`); process.exit(2); } +let rele = 0, n = 0; +const cer = (nume, ok, extra = '') => { n++; console.log(` [${ok ? 'OK ' : 'RAU '}] ${nume}${extra ? ' (' + extra + ')' : ''}`); if (!ok) rele++; }; +const nemasurat = (m) => { console.log('NEMASURAT: ' + m); process.exit(2); }; + +// ---- producatorul 1: inventarul real, pe material facut acum +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'raport-conf-')); +let plan; +try { + try { + execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(T, 'api.key'), '-out', path.join(T, 'api.crt'), + '-days', '800', '-subj', '/CN=api.intern'], { stdio: 'ignore' }); + } catch (e) { nemasurat('openssl nu a putut face certificatul de proba: ' + e.message); } + fs.writeFileSync(path.join(T, 'serviciu.js'), "const crypto = require('crypto');\nconst e = crypto.createECDH('prime256v1');\nconst s = crypto.sign('RSA-SHA256', Buffer.from('x'), cheie);\n"); + const { caleaScanerului } = await import('./control-plane.mjs'); + const scanerSrc = fs.readFileSync(caleaScanerului(), 'utf8'); + const re = /add\('([a-z0-9-]+)',\s*'([a-z]+)',\s*[`']([^`']+)[`']/g; const reale = []; let m; + while ((m = re.exec(scanerSrc))) reale.push({ id: m[1], severity: m[2], title: m[3] }); + cer(`scanerul: id-uri extrase din sursa (control pozitiv al extragerii: >= 10)`, new Set(reale.map((x) => x.id)).size >= 10, String(new Set(reale.map((x) => x.id)).size)); + const ia = (id) => reale.find((x) => x.id === id); + const scan = { domain: 'client.exemplu.com', findings: ['hndl-exposed', 'tls12-accepted', 'auth-classical'].map(ia).filter(Boolean) }; + cer('scanarea de proba are cele trei constatari reale', scan.findings.length === 3); + fs.writeFileSync(path.join(T, 'scan.json'), JSON.stringify(scan)); + const r = spawnSync(process.execPath, [path.join(AICI, 'control-plane.mjs'), '--code', T, '--scan', path.join(T, 'scan.json'), '--json'], { encoding: 'utf8' }); + if (r.status !== 0) nemasurat('control-plane.mjs a iesit ' + r.status + ': ' + (r.stderr || '').slice(0, 200)); + plan = JSON.parse(r.stdout); + cer(`inventarul real a vazut ceva vulnerabil (${plan.inventory.quantumVulnerable} folosiri) si planul are actiuni (${plan.summary.total})`, plan.inventory.quantumVulnerable > 0 && plan.summary.total > 0); +} finally { fs.rmSync(T, { recursive: true, force: true }); } + +// ---- raportul +const { report: raport, envelope: plic, evidenceHash } = raportConformitate({ plan, organization: 'Exemplu SRL', date: '2026-09-28' }); +const dupa = (f) => raport.findings.find(f); +const kex = dupa((c) => c.ref.startsWith('tls-kex:')); +cer('schimbul de cheie clasic (scanare) e schimb-de-cheie, expus AZI, cu termenele NIST 2030 si UE 2030 (risc nedeclarat = inalt)', + kex && kex.nature === 'key-exchange' && kex.exposedToday && kex.regimes.some((x) => x.regime === 'nist-ir-8547' && x.deadline === '2030-12-31') + && kex.regimes.some((x) => x.regime === 'eu-2025' && x.deadline === '2030-12-31' && /not declared/.test(x.what))); +cer('zilele pana la termen se numara de la data raportului (2026-09-28 -> 2030-12-31 = 1555)', kex && kex.regimes[0].days === 1555, kex && String(kex.regimes[0].days)); +const auth = dupa((c) => c.ref.startsWith('tls-cert:')); +cer('autentificarea clasica e semnatura, BLOCATA de ecosistem, NU expusa azi, dar cu termen', auth && auth.nature === 'signature' && auth.method === 'blocked' && !auth.exposedToday && auth.nearestDeadline === '2030-12-31'); +const conf = dupa((c) => c.ref.includes(':tls12-accepted')); +cer('TLS 1.2 acceptat e configuratie, fara termen de algoritm', conf && conf.nature === 'configuration' && conf.regimes.length === 0 && conf.nearestDeadline === null); +const inv = raport.findings.filter((c) => c.ref && !c.ref.startsWith('tls-')); +cer(`activele din inventar sunt clasificate (niciunul neclasificat): ${inv.map((c) => c.nature).join(', ')}`, inv.length > 0 && inv.every((c) => c.nature !== 'unclassified')); +const cheie = inv.find((c) => c.nature === 'key-use-unknown'); +cer('cheia privata RSA a inventarului: folosire necunoscuta, cu termenele NIST/UE, dar "expusa azi" = NESTIUT (null), nu fals', cheie && cheie.exposedToday === null && cheie.nearestDeadline === '2030-12-31'); +cer('rezumatul: niciun neclasificat, cel mai apropiat termen 2030-12-31, verdictul numeste expunerea de azi', + raport.summary.unclassified === 0 && raport.summary.nearestDeadline === '2030-12-31' && /exposed TODAY/.test(raport.summary.verdict)); +cer('fara CNSA cerut, regimul CNSA nu apare in raport', !raport.regimes['cnsa-2.0'] && raport.findings.every((c) => c.regimes.every((x) => x.regime !== 'cnsa-2.0'))); +cer('fiecare regim din raport isi poarta sursa, data sursei si data citirii', Object.values(raport.regimes).every((g) => g.source && g.sourceDate && g.read)); + +// ---- plicul AIP-23, prin verificatorul de referinta publicat +const T2 = fs.mkdtempSync(path.join(os.tmpdir(), 'raport-plic-')); +try { + fs.writeFileSync(path.join(T2, 'plic.json'), JSON.stringify(plic)); + const v = spawnSync(process.execPath, [VERIFY, path.join(T2, 'plic.json'), '--json'], { encoding: 'utf8' }); + let j = null; try { j = JSON.parse(v.stdout); } catch { /* */ } + cer('plicul compliance trece prin verify-proof.mjs: VALID, integritate PASSED', j && v.status === 0 && j.verdict === 'VALID' && j.levels.some((l) => l.level === 'integrity' && l.state === 'PASSED'), j ? j.verdict : 'fara JSON'); + cer('plicul leaga raportul: evidenceHash = amprenta canonica a raportului', plic.statement.evidenceHash === evidenceHash && evidenceHash === sha256Canonic(raport)); + cer('verificaRaport pe raportul emis: ok', verificaRaport(raport, plic).ok); + // CONTROL NEGATIV: un raport atins (o zi mai mult pana la un termen) nu mai e cel legat de plic + const atins = JSON.parse(JSON.stringify(raport)); atins.findings[0].regimes[0] && (atins.findings[0].regimes[0].days += 1); + cer('CONTROL: raportul atins NU mai trece verificaRaport', !verificaRaport(atins, plic).ok); + // CONTROL NEGATIV: plicul atins dupa hash pica la verificatorul de referinta + const pa = JSON.parse(JSON.stringify(plic)); pa.statement.result = 'conform'; + fs.writeFileSync(path.join(T2, 'atins.json'), JSON.stringify(pa)); + const va = spawnSync(process.execPath, [VERIFY, path.join(T2, 'atins.json'), '--json'], { encoding: 'utf8' }); + let ja = null; try { ja = JSON.parse(va.stdout); } catch { /* */ } + cer('CONTROL: plicul cu rezultatul schimbat dupa hash e INVALID la verificator', ja && va.status === 1 && ja.verdict === 'INVALID', ja ? ja.verdict : 'fara JSON'); +} finally { fs.rmSync(T2, { recursive: true, force: true }); } + +// ---- riscul si CNSA +const rm = raportConformitate({ plan, organization: 'Exemplu SRL', date: '2026-09-28', risk: 'medium' }).report; +cer('risc mediu: termenul UE devine 2035-12-31', rm.findings.find((c) => c.ref.startsWith('tls-kex:')).regimes.some((x) => x.regime === 'eu-2025' && x.deadline === '2035-12-31')); +const rc = raportConformitate({ plan, organization: 'Exemplu SRL', date: '2026-09-28', cnsa: true }).report; +const kc = rc.findings.find((c) => c.ref.startsWith('tls-kex:')).regimes.find((x) => x.regime === 'cnsa-2.0'); +cer('CNSA: TLS-ul scanat e "servere web si servicii cloud", exclusiv 2033, marcat PRESUPUS', kc && kc.category === 'web-cloud' && kc.deadline === '2033-12-31' && kc.assumed === true); +const ic = rc.findings.find((c) => !c.ref.startsWith('tls-') && c.nature !== 'configuration'); +const icc = ic && ic.regimes.find((x) => x.regime === 'cnsa-2.0'); +cer('CNSA: un activ din inventar fara categorie data NU primeste un termen inventat', icc && icc.deadline === null && /not given/.test(icc.what)); +const refInv = ic && ic.ref; +const rcs = raportConformitate({ plan, organization: 'Exemplu SRL', date: '2026-09-28', cnsa: true, cnsaCategories: { [refInv]: 'software-signing' } }).report; +const icx = rcs.findings.find((c) => c.ref === refInv).regimes.find((x) => x.regime === 'cnsa-2.0'); +cer('CNSA: categoria data explicit (semnare software) da termenul ei, 2030, nepresupus', icx && icx.deadline === '2030-12-31' && icx.assumed === false); + +// ---- CONTROL NEGATIV al metodei: un plan pe un inventar numai PQ nu are nimic de raportat +const curat = raportConformitate({ plan: planeaza({ inventory: [{ ref: 'a', assetType: 'algorithm', name: 'ML-KEM-768', primitive: 'kem', quantumVulnerable: false }] }), organization: 'X', date: '2026-09-28' }).report; +cer('CONTROL: inventar numai PQ -> zero actiuni, fara termen, verdictul o spune', curat.summary.actions === 0 && curat.summary.nearestDeadline === null && /no quantum-vulnerable/.test(curat.summary.verdict)); +cer('CONTROL: o actiune cu ref necunoscut NU e ghicita, e numarata neclasificata', natura({ ref: 'altceva:x', source: 'scan' }) === 'unclassified'); +let arunca = false; try { raportConformitate({ plan, organization: 'X' }); } catch { arunca = true; } +cer('CONTROL: fara data raportul refuza (e o fotografie datata, nu "azi" implicit)', arunca); +// 2026-09-29, revizuirea adversariala inainte de publicare (RC1-RC3) +const numaiNeclasificat = raportConformitate({ plan: { actions: [{ ref: 'altceva:x', source: 'scan', asset: 'x' }] }, organization: 'X', date: '2026-09-29' }).report; +cer('RC1: numai actiuni neclasificate -> verdictul NU spune "fara algoritmi vulnerabili"', !/no quantum-vulnerable/.test(numaiNeclasificat.summary.verdict) && /UNCLASSIFIED/.test(numaiNeclasificat.summary.verdict), numaiNeclasificat.summary.verdict.slice(0, 80)); +const refuza = (o) => { try { raportConformitate(o); return false; } catch (e) { return /real calendar day/.test(e.message); } }; +cer('RC2: o data de forma buna dar inexistenta (2026-13-45, 2026-02-30) e refuzata cu motivul ei', refuza({ plan, organization: 'X', date: '2026-13-45' }) && refuza({ plan, organization: 'X', date: '2026-02-30' })); +const refKex = plan.actions.find((a) => String(a.ref).startsWith('tls-kex:'))?.ref; +let rc3; try { rc3 = raportConformitate({ plan, organization: 'X', date: '2026-09-29', cnsa: true, cnsaCategories: { [refKex]: 'constructor' } }).report; } catch (e) { rc3 = e; } +const kc3 = rc3 && rc3.findings ? rc3.findings.find((c) => c.ref === refKex).regimes.find((x) => x.regime === 'cnsa-2.0') : null; +cer('RC3: o categorie CNSA care e un nume mostenit din prototip ("constructor") nu primeste termen si nu arunca', !!kc3 && kc3.deadline === null, rc3 instanceof Error ? rc3.message.slice(0, 60) : kc3 && kc3.what); + +console.log(`\nraportul de conformitate: ${n - rele}/${n} cum trebuia`); +process.exitCode = rele ? 1 : 0; diff --git a/control-plane/proba-remediere.mjs b/control-plane/proba-remediere.mjs new file mode 100644 index 0000000..f495387 --- /dev/null +++ b/control-plane/proba-remediere.mjs @@ -0,0 +1,133 @@ +// Proba remedierii B1 (remediere.mjs), pe servere TLS REALE pornite local si scanate de scanerul AERE (scaneazaAdresa din +// readiness-service.mjs, aceeasi masuratoare ca /v1/pq/readiness, pe 127.0.0.1 si alt port). Fara retea externa. +// 1. acoperirea: fiecare id pe care scanerul il poate emite (extras din SURSA lui) si pe care planificatorul il face actiune +// manual/auto are reteta si judecator; controlul: un id nou, plantat, e prins ca neacoperit +// 2. fragmentele generate poarta fiecare setare, pe fiecare profil (grupurile, versiunea, HSTS) +// 3. grupurile scrise in fragmentele nginx/apache/haproxy/node sunt acceptate de OpenSSL (3.5); controlul: un nume stricat e refuzat +// 4. cap la cap, profilul node: server CLASIC -> scanare -> plan -> reteta -> server cu OPTIUNILE RETETEI (luate din reteta, nu +// rescrise) -> rescanare -> REZOLVAT pe hndl-exposed, tls12-accepted, hsts-missing; chain-untrusted (autosemnat) NEREZOLVAT +// 5. controale negative: remediere falsa (numai HSTS) -> NEREZOLVAT pe schimbul de cheie si pe TLS 1.2; "PQ nepreferat" disparut +// fiindca PQ a disparut de tot -> NEREZOLVAT, nu REZOLVAT; alta gazda, scanare de dinainte de aplicare, scanare cazuta -> NEMASURAT +// 6. lantul judecatilor se re-verifica; o judecata schimbata e prinsa +// node proba-remediere.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import tls from 'node:tls'; +import https from 'node:https'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { planeaza, CLASIFICARE_SCAN } from './plan-migrare.mjs'; +import { reteta, verifica, verificaRemedierea, REMEDIERI, PROFILURI, defectDinRef } from './remediere.mjs'; +import { pathToFileURL } from 'node:url'; +import { caleaScanerului } from './control-plane.mjs'; +// scanerul se incarca de unde e (readiness/ intr-o copie publica, langa planul de control in depozitul de dezvoltare) +const { scaneazaAdresa } = await import(pathToFileURL(caleaScanerului()).href); + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +let bune = 0, rele = 0; +const cer = (nume, ok) => { if (ok) { bune++; console.log(` OK ${nume}`); } else { rele++; console.log(` RAU ${nume}`); } }; + +// 1. acoperirea, derivata din sursa scanerului +const SRC = fs.readFileSync(caleaScanerului(), 'utf8'); +const idsScaner = [...new Set([...SRC.matchAll(/add\('([a-z0-9-]+)',\s*'([a-z]+)'/g)].map((m) => m[1]))]; +function neacoperite(ids) { + return ids.filter((id) => { + const a = Object.hasOwn(CLASIFICARE_SCAN, id) ? CLASIFICARE_SCAN[id]({ id }, 'x.exemplu.com') : { method: 'manual' }; + if (!a || a.method === 'blocked') return false; // informativ sau blocat onest: nu are ce remedia pe server + return !Object.hasOwn(REMEDIERI, id); + }); +} +cer(`controlul metodei: din sursa scanerului ies ${idsScaner.length} id-uri (cerut >= 8)`, idsScaner.length >= 8); +cer(`acoperirea: fiecare defect remediabil al scanerului are reteta (neacoperite: ${neacoperite(idsScaner).join(',') || 'niciunul'})`, neacoperite(idsScaner).length === 0); +cer('CONTROL: un id nou al scanerului, fara reteta, e prins', neacoperite([...idsScaner, 'tls-ceva-nou']).join() === 'tls-ceva-nou'); + +// 2. fragmentele poarta fiecare setare +const planToate = planeaza({ scan: { domain: 'toate.exemplu.com', findings: ['hndl-exposed', 'tls12-accepted', 'hsts-missing', 'tls13-missing', 'rsa-short', 'chain-untrusted', 'cert-expiring'].map((id) => ({ id, severity: 'medium' })) } }); +for (const p of PROFILURI) { + const r = reteta(planToate, p); const d = r.domains[0]; const t = d.config.fragment.join('\n'); + cer(`${p}: grupul hibrid primul, versiunea 1.3, HSTS in fragment; ${d.operational.length} pasi operationali (certificatul)`, + /X25519MLKEM768[:,] ?(tls\.)?X25519/.test(t) && /1\.3|TLSv1\.3|TLS13/.test(t) && /Strict-Transport-Security/i.test(t) && d.operational.length === 3 && typeof r.measured === 'string'); +} +cer('actiunile de cod (inventar) raman fara reteta de server, cu motivul scris', reteta({ actions: [{ ref: 'algorithm:rsa', method: 'manual' }] }, 'nginx').none.length === 1); +let aruncat = false; try { reteta(planToate, 'constructor'); } catch { aruncat = true; } +cer('un profil necunoscut (si unul mostenit din prototip) e refuzat', aruncat); + +// 3. grupurile din fragmente, acceptate de OpenSSL +const grupuriDin = (p) => { const t = reteta(planToate, p).domains[0].config.fragment.join('\n'); const m = t.match(/(X25519MLKEM768:[A-Za-z0-9:]+)/); return m && m[1]; }; +for (const p of ['nginx', 'apache', 'haproxy', 'node']) { + const g = grupuriDin(p); let ok = false; try { tls.createSecureContext({ ecdhCurve: g }); ok = true; } catch {} + cer(`${p}: lista "${g}" acceptata de OpenSSL ${process.versions.openssl}`, !!g && ok); +} +let refuzat = false; try { tls.createSecureContext({ ecdhCurve: 'X25519MLKEM768:X25519BOGUS' }); } catch { refuzat = true; } +cer('CONTROL: o lista cu un grup stricat e refuzata de OpenSSL (metoda poate iesi rosie)', refuzat); + +// 4-6. cap la cap pe servere reale +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-b1-remediere-')); +const DOM = 'remediere.proba.invalid'; +execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '365', '-subj', `/CN=${DOM}`, + '-addext', `subjectAltName=DNS:${DOM}`, '-keyout', path.join(T, 'k.pem'), '-out', path.join(T, 'c.pem')], { stdio: 'ignore' }); +const cert = fs.readFileSync(path.join(T, 'c.pem')), key = fs.readFileSync(path.join(T, 'k.pem')); +function server(opt, antete = {}) { + return new Promise((res) => { + const s = https.createServer({ cert, key, ...opt }, (q, r) => { for (const [k, v] of Object.entries(antete)) r.setHeader(k, v); r.end('ok'); }); + s.listen(0, '127.0.0.1', () => res(s)); + }); +} +const scaneaza = (s) => scaneazaAdresa(DOM, { address: '127.0.0.1', family: 4 }, { port: s.address().port, jurnal: false }); +const inchide = (s) => new Promise((r) => s.close(() => r())); + +try { + const clasic = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2' }); + const inainte = await scaneaza(clasic); + const ids = (inainte.findings || []).map((f) => f.id); + cer(`serverul clasic, scanat: ${ids.join(',')}`, ids.includes('hndl-exposed') && ids.includes('tls12-accepted') && ids.includes('hsts-missing')); + const plan = planeaza({ scan: { domain: DOM, findings: inainte.findings } }); + const r = reteta(plan, 'node'); const d = r.domains.find((x) => x.domain === DOM); + cer(`reteta node: optiunile ${JSON.stringify(d.config.options)}, antete ${Object.keys(d.config.headers).join(',')}`, d.config.options.ecdhCurve && d.config.options.minVersion === 'TLSv1.3' && d.config.headers['strict-transport-security']); + await inchide(clasic); + const aplicatLa = new Date().toISOString(); + await new Promise((z) => setTimeout(z, 20)); + const reparat = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2', ...d.config.options }, d.config.headers); + const dupa = await scaneaza(reparat); + await inchide(reparat); + const v = verifica(plan, dupa, { appliedAt: aplicatLa }); + const st = Object.fromEntries(v.results.map((x) => [x.defect, x.state])); + cer(`dupa reteta: schimb de cheie ${st['hndl-exposed']}, TLS 1.2 ${st['tls12-accepted']}, HSTS ${st['hsts-missing']}`, st['hndl-exposed'] === 'RESOLVED' && st['tls12-accepted'] === 'RESOLVED' && st['hsts-missing'] === 'RESOLVED'); + cer(`lantul autosemnat ramane ${st['chain-untrusted']} (operational, nu o setare): nu se pretinde rezolvat`, st['chain-untrusted'] === 'UNRESOLVED'); + cer(`dupa reteta, scanerul vede PQ: ${dupa.summary.pqKeyExchange}`, dupa.summary.pqKeyExchange === 'X25519MLKEM768'); + + // 5. controale negative + const fals = await server({ ecdhCurve: 'X25519:P-256', minVersion: 'TLSv1.2' }, d.config.headers); + const dupaFals = await scaneaza(fals); await inchide(fals); + const vf = Object.fromEntries(verifica(plan, dupaFals, { appliedAt: aplicatLa }).results.map((x) => [x.defect, x.state])); + cer(`CONTROL: remediere falsa (numai HSTS): schimb de cheie ${vf['hndl-exposed']}, TLS 1.2 ${vf['tls12-accepted']}, HSTS ${vf['hsts-missing']}`, vf['hndl-exposed'] === 'UNRESOLVED' && vf['tls12-accepted'] === 'UNRESOLVED' && vf['hsts-missing'] === 'RESOLVED'); + const planPref = { actions: [{ ref: CLASIFICARE_SCAN['pq-not-preferred']({}, DOM).ref, method: 'manual' }] }; + const vp = verifica(planPref, dupaFals, { appliedAt: aplicatLa }).results[0]; + cer(`CONTROL: "PQ nepreferat" absent fiindca PQ lipseste de tot -> ${vp.state} (${vp.reason})`, vp.state === 'UNRESOLVED'); + cer('CONTROL: scanarea altei gazde -> NEMASURAT', verifica(plan, { ...dupa, domain: 'alta.exemplu.com' }, { appliedAt: aplicatLa }).results.every((x) => x.state === 'UNMEASURED')); + cer('CONTROL: scanarea de DINAINTE de aplicare -> NEMASURAT', verifica(plan, inainte, { appliedAt: aplicatLa }).results.every((x) => x.state === 'UNMEASURED')); + cer('CONTROL: scanarea cazuta -> NEMASURAT', verifica(plan, { domain: DOM, error: 'no_tls' }).results.every((x) => x.state === 'UNMEASURED')); + cer('ref-urile planului se citesc inapoi in defect si domeniu (derivat din clasificator)', v.results.every((x) => defectDinRef(x.ref)?.domain === DOM)); + // R2 (2026-09-29): fara margine de timp (nici --aplicat-la, nici generatedAt) nicio judecata nu iese REZOLVAT; cu generatedAt ca margine se judeca + cer('R2: fara momentul aplicarii si fara generatedAt -> NEMASURAT, nu verde pe o scanare de oricand', verifica(plan, dupa).results.every((x) => x.state === 'UNMEASURED')); + cer('R2: planul generat DUPA scanare -> NEMASURAT', verifica({ ...plan, generatedAt: new Date(Date.parse(dupa.measuredAt) + 60000).toISOString() }, dupa).results.every((x) => x.state === 'UNMEASURED')); + const vg = Object.fromEntries(verifica({ ...plan, generatedAt: aplicatLa }, dupa).results.map((x) => [x.defect, x.state])); + cer(`R2: cu generatedAt ca margine se judeca (schimb de cheie ${vg['hndl-exposed']})`, vg['hndl-exposed'] === 'RESOLVED'); + // R1 (2026-09-29): un domeniu care nu e nume de gazda nu intra in nicio comanda generata + const rau = 'x.exemplu.com; touch /tmp/aere-pwn'; + const planRau = { actions: ['chain-untrusted', 'cert-expiring', 'hsts-missing'].map((id) => ({ ref: CLASIFICARE_SCAN[id]({ id }, rau).ref, method: 'manual' })) }; + const rr = reteta(planRau, 'nginx'); const tot = JSON.stringify(rr.domains); + cer(`R1: domeniul "${rau}" nu produce nicio configuratie sau comanda (${rr.none.length} actiuni refuzate)`, !tot.includes('touch') && rr.domains.length === 0 && rr.none.length === 3); + const rb = reteta({ actions: [{ ref: CLASIFICARE_SCAN['chain-untrusted']({ id: 'chain-untrusted' }, DOM).ref, method: 'manual' }] }, 'nginx'); + cer('R1 CONTROL: un nume de gazda valid produce comanda cu el', JSON.stringify(rb.domains).includes(`-servername ${DOM}`)); + + // 6. lantul + cer('lantul judecatilor se re-verifica', verificaRemedierea(v.records).ok); + const alterat = JSON.parse(JSON.stringify(v.records)); const i = alterat.findIndex((e) => e.record.state === 'UNRESOLVED'); alterat[i].record.state = 'RESOLVED'; + cer('CONTROL: o judecata NEREZOLVAT rescrisa REZOLVAT e prinsa', verificaRemedierea(alterat).ok === false); +} catch (e) { rele++; console.log(` RAU cap la cap a cazut: ${e.message}`); } +finally { fs.rmSync(T, { recursive: true, force: true }); } + +console.log(`\n${bune} treceri, ${rele} esecuri`); +process.exitCode = rele ? 1 : 0; diff --git a/control-plane/raport-conformitate.mjs b/control-plane/raport-conformitate.mjs new file mode 100644 index 0000000..63ff218 --- /dev/null +++ b/control-plane/raport-conformitate.mjs @@ -0,0 +1,176 @@ +// raport-conformitate.mjs - AERE Quantum (roadmap master, punctul 16): RAPORTUL DE CONFORMITATE post-cuantica al unei organizatii, +// construit peste planul de migrare (plan-migrare.mjs, care citeste inventarul crypto-inventory si scanarea readiness-service) si +// legat de un plic AERE Proof Protocol (AIP-23) de fel `compliance`, ca oricine sa poata verifica ce raport a fost emis. +// +// CE SPUNE: pentru fiecare actiune din plan, ce regimuri i se aplica, termenul fiecaruia si cate zile mai sunt la data raportului. +// CE NU SPUNE: raportul NU e o certificare si NU e o opinie juridica; termenele sunt ale documentelor numite, citite la data scrisa +// langa fiecare regim, iar una din surse (CNSA 2.0) e citita dintr-o sursa secundara, spus ca atare (sursa primara NSA refuza +// descarcarea automata, 403, masurat 2026-09-28). +// +// REGIMURILE (fiecare cu sursa si data citirii): +// - NIST IR 8547 ipd (2024-11-12, proiect, nefinalizat): algoritmii cu cheie publica vulnerabili cuantic (RSA, ECDSA, ECDH, DH) +// DEPRECATI dupa 2030 la nivelul de 112 biti, INTERZISI dupa 2035. Se aplica schimbului de cheie si semnaturilor. +// - Foaia de parcurs coordonata a UE (2025-06-23): inventar si planuri pana la 31.12.2026; pentru cazurile cu RISC INALT, tranzitia +// pana la 31.12.2030 ("quantum-vulnerable public key mechanisms shall not be used stand-alone after the end of 2030"); risc mediu +// pana la 31.12.2035. Un mecanism HIBRID (clasic + PQ) nu e "stand-alone", deci e conform la 2030. +// - CNSA 2.0 (NSA, pentru sistemele de securitate nationala; profil OPTIONAL): servere web si servicii cloud: suport si preferinta +// din 2025, exclusiv din 2033; semnarea software/firmware: 2025 / 2030; echipamente de retea: 2026 / 2030; sisteme de operare: +// 2027 / 2033. Categoria unui activ nu se vede din inventar: se da explicit (`cnsaCategories`), altfel se presupune "web-cloud" +// pentru ce vine din scanarea TLS si se SCRIE ca presupunere. +// Raportul e in ENGLEZA (forma 2, 2026-09-29): il citesc clientul si auditorul lui. +// +// import { raportConformitate, verificaRaport } from './raport-conformitate.mjs' +// const r = raportConformitate({ plan, organization, risk: 'high'|'medium', date: '2026-09-28', cnsa: false|true, cnsaCategories }) +// -> { report, envelope, evidenceHash } (envelope = AIP-23 compliance, evidenceHash = sha256 al raportului canonic) +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// constructorul de plicuri: langa planul de control intr-o copie publica (../proof-kinds), sau in depozitul de dezvoltare (tools/) +const PK = [new URL('../proof-kinds/proof-kinds.mjs', import.meta.url), new URL('../../tools/proof-kinds/proof-kinds.mjs', import.meta.url)].find((u) => fs.existsSync(fileURLToPath(u))); +if (!PK) throw new Error('raport-conformitate: the proof-kinds builder is not beside the control plane (../proof-kinds) nor in tools/'); +const { buildProof } = await import(PK.href); + +export const VERSIUNE = 'aere-quantum/compliance-report/2 (2026-09-29)'; +export const REGIMURI = Object.freeze({ + 'nist-ir-8547': Object.freeze({ + name: 'NIST IR 8547 ipd, Transition to Post-Quantum Cryptography Standards', + source: 'https://nvlpubs.nist.gov/nistpubs/ir/2024/NIST.IR.8547.ipd.pdf', sourceDate: '2024-11-12', read: '2026-09-28', verified: 'primary', + status: 'initial public draft, not final on the date it was read', + deadlines: Object.freeze({ deprecated: '2030-12-31', disallowed: '2035-12-31' }), + }), + 'eu-2025': Object.freeze({ + name: 'A Coordinated Implementation Roadmap for the Transition to Post-Quantum Cryptography (EU)', + source: 'https://digital-strategy.ec.europa.eu/en/library/coordinated-implementation-roadmap-transition-post-quantum-cryptography', + sourceDate: '2025-06-23', read: '2026-09-28', verified: 'primary (the date) and secondary (the text of the deadlines)', + deadlines: Object.freeze({ inventory: '2026-12-31', highRisk: '2030-12-31', mediumRisk: '2035-12-31' }), + }), + 'cnsa-2.0': Object.freeze({ + name: 'CNSA 2.0 (NSA), national security systems', + source: 'https://media.defense.gov/2022/Sep/07/2003071836/-1/-1/0/CSI_CNSA_2.0_FAQ_.PDF', sourceDate: '2022-09-07', read: '2026-09-28', + verified: 'secondary: the primary source refuses automated download (403); the same table in two secondary sources', + categories: Object.freeze({ + 'web-cloud': Object.freeze({ name: 'web servers and cloud services', prefer: '2025-12-31', exclusive: '2033-12-31' }), + 'software-signing': Object.freeze({ name: 'software and firmware signing', prefer: '2025-12-31', exclusive: '2030-12-31' }), + 'networking': Object.freeze({ name: 'networking equipment (VPNs, routers)', prefer: '2026-12-31', exclusive: '2030-12-31' }), + 'operating-systems': Object.freeze({ name: 'operating systems', prefer: '2027-12-31', exclusive: '2033-12-31' }), + }), + }), +}); + +// natura unei actiuni din plan: schimb de cheie (HNDL), semnatura/autentificare, sau configuratie (nu un algoritm vulnerabil). +// Pentru actiunile din inventar se citeste primitiva scrisa de planificator (campul `primitive`), nu textul problemei. +export function natura(a) { + const ref = String(a.ref || ''); + if (ref.startsWith('tls-kex:')) return 'key-exchange'; + if (ref.startsWith('tls-cert:')) return 'signature'; + if (ref.startsWith('tls-conf:') || ref.startsWith('tls-cert-expiry:') || ref.startsWith('tls-cert-rsa:')) return 'configuration'; + if (a.source === 'inventory') { + const p = String(a.primitive || ''); + if (p === 'key-agree' || p === 'kem' || p === 'pke') return 'key-exchange'; + if (p === 'signature') return 'signature'; + // material cu cheie publica vulnerabil a carui FOLOSIRE nu se vede din fisier (ex. o cheie privata RSA: semnatura sau cifrare); + // prins de proba pe iesirea reala a inventarului (28 sept): prima forma il lasa "neclasificat" + return 'key-use-unknown'; + } + return 'unclassified'; +} + +const zile = (de, pana) => Math.floor((Date.parse(pana + 'T23:59:59Z') - Date.parse(de + 'T00:00:00Z')) / 86400e3); + +/** + * @param {{plan: object, organization: string, risk?: 'high'|'medium', date: string, cnsa?: boolean, cnsaCategories?: object}} p + */ +export function raportConformitate({ plan, organization, risk, date, cnsa = false, cnsaCategories = {} } = {}) { + if (!plan || !Array.isArray(plan.actions)) throw new Error('compliance report: a plan without actions (the output of the planner is needed)'); + if (!organization) throw new Error('compliance report: organization is required'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) throw new Error('compliance report: date is required, YYYY-MM-DD (the report is a dated snapshot)'); + // RC2 (2026-09-29): o data de forma buna dar inexistenta (2026-13-45) dadea zile NaN in raport; se cere o zi calendaristica reala + const zi0 = new Date(date + 'T00:00:00Z'); + if (Number.isNaN(zi0.getTime()) || zi0.toISOString().slice(0, 10) !== date) throw new Error('compliance report: date is not a real calendar day'); + if (risk !== undefined && risk !== 'high' && risk !== 'medium') throw new Error('compliance report: risk = high | medium'); + const N = REGIMURI['nist-ir-8547'], U = REGIMURI['eu-2025'], C = REGIMURI['cnsa-2.0']; + const constatari = []; + for (const a of plan.actions) { + const nat = natura(a); + const reg = []; + if (nat === 'key-exchange' || nat === 'signature' || nat === 'key-use-unknown') { + reg.push({ regime: 'nist-ir-8547', deadline: N.deadlines.deprecated, what: 'deprecated after 2030 (112 bits); disallowed after 2035', days: zile(date, N.deadlines.deprecated) }); + const tu = risk === 'medium' ? U.deadlines.mediumRisk : U.deadlines.highRisk; + reg.push({ regime: 'eu-2025', deadline: tu, what: risk === 'medium' ? 'medium risk: transition by 2035' : risk === 'high' + ? 'high risk: no vulnerable mechanism used stand-alone after 2030 (hybrid is compliant)' : 'risk not declared: the high-risk deadline (2030) applies until it is declared', + days: zile(date, tu) }); + if (cnsa) { + // RC3 (2026-09-29): numai chei PROPRII (Object.hasOwn); o categorie ca 'constructor' sau un ref '__proto__' luau valori mostenite din prototip + const data_ = Object.hasOwn(cnsaCategories, a.ref) ? cnsaCategories[a.ref] : null; + const cat = data_ || (String(a.ref).startsWith('tls-') ? 'web-cloud' : null); + const cc = typeof cat === 'string' && Object.hasOwn(C.categories, cat) ? C.categories[cat] : null; + if (cc) reg.push({ regime: 'cnsa-2.0', category: cat, assumed: !data_, deadline: cc.exclusive, prefer: cc.prefer, + what: `${cc.name}: supported and preferred by ${cc.prefer.slice(0, 4)}, exclusive by ${cc.exclusive.slice(0, 4)}`, days: zile(date, cc.exclusive) }); + else reg.push({ regime: 'cnsa-2.0', category: null, deadline: null, what: 'the CNSA category of the asset is not given (cnsaCategories); the deadline cannot be set', days: null }); + } + } + const termene = reg.filter((r) => r.deadline).map((r) => r.deadline).sort(); + constatari.push({ + ref: a.ref, asset: a.asset, problem: a.problem, nature: nat, urgency: a.urgency, method: a.method, product: a.product || null, + target: a.target, blocker: a.blocker || null, regimes: reg, nearestDeadline: termene[0] || null, + // HNDL: schimbul de cheie e expus AZI (datele se recolteaza acum); semnatura nu (falsificarea vine odata cu calculatorul cuantic) + // null = nu se stie: o cheie folosita la cifrare/transport e expusa azi, una folosita la semnatura nu + exposedToday: nat === 'key-exchange' ? true : nat === 'key-use-unknown' ? null : false, + }); + } + const cuTermen = constatari.filter((c) => c.nearestDeadline); + const rezumat = { + actions: constatari.length, + keyExchange: constatari.filter((c) => c.nature === 'key-exchange').length, + signatures: constatari.filter((c) => c.nature === 'signature').length, + unknownUse: constatari.filter((c) => c.nature === 'key-use-unknown').length, + configuration: constatari.filter((c) => c.nature === 'configuration').length, + unclassified: constatari.filter((c) => c.nature === 'unclassified').length, + exposedToday: constatari.filter((c) => c.exposedToday === true).length, + blockedByEcosystem: constatari.filter((c) => c.method === 'blocked').length, + nearestDeadline: cuTermen.map((c) => c.nearestDeadline).sort()[0] || null, + euInventory2026: date <= U.deadlines.inventory ? 'this report is a dated inventory (the EU requirement for 31.12.2026)' : 'the EU inventory deadline (31.12.2026) has passed', + }; + const vulnerabile = rezumat.keyExchange + rezumat.signatures + rezumat.unknownUse; + // RC1 (2026-09-29): actiunile NECLASIFICATE nu sunt judecate; cu ele, raportul nu are voie sa spuna "fara algoritmi vulnerabili" + rezumat.verdict = vulnerabile === 0 && rezumat.unclassified > 0 + ? `no action classified as vulnerable, but ${rezumat.unclassified} UNCLASSIFIED actions (not judged); the report cannot say that there is no vulnerable algorithm` + : vulnerabile === 0 + ? 'no quantum-vulnerable public-key algorithm in what was measured' + : `${vulnerabile} quantum-vulnerable assets; nearest deadline ${rezumat.nearestDeadline}${rezumat.exposedToday ? `; ${rezumat.exposedToday} exposed TODAY (harvest now, decrypt later)` : ''}${rezumat.unknownUse ? `; ${rezumat.unknownUse} of unknown use (exposure today not known)` : ''}`; + const raport = { + v: 2, version: VERSIUNE, organization, date, risk: risk || null, cnsa: !!cnsa, + regimes: Object.fromEntries(Object.entries(REGIMURI).filter(([k]) => cnsa || k !== 'cnsa-2.0')), + plan: { version: plan.version || null, summary: plan.summary || null }, + summary: rezumat, findings: constatari, + limits: [ + 'The report covers only what was inventoried and scanned; an asset that was not measured does not appear, and its absence does not mean compliance.', + 'It is not a certification and not a legal opinion; the deadlines are those of the documents named, as read on the date written beside each.', + 'NIST IR 8547 was a draft when it was read; its deadlines may change in the final version.', + ], + }; + const evidenta = sha256Canonic(raport); + const plic = buildProof('compliance', { + subject: organization, policy: VERSIUNE, result: rezumat.verdict, evidenceHash: evidenta, createdAt: date + 'T00:00:00Z', + }); + return { report: raport, envelope: plic, evidenceHash: evidenta }; +} +export { raportConformitate as complianceReport }; + +// JSON canonic: chei sortate recursiv, fara spatii; amprenta lui e evidenceHash din plic +export function canonicJson(v) { + if (Array.isArray(v)) return '[' + v.map(canonicJson).join(',') + ']'; + if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicJson(v[k])).join(',') + '}'; + return JSON.stringify(v); +} +export const sha256Canonic = (o) => '0x' + crypto.createHash('sha256').update(canonicJson(o), 'utf8').digest('hex'); + +/** raportul primit e chiar cel legat de plic? (amprenta canonica == evidenceHash; plicul trece separat prin verify-proof.mjs) */ +export function verificaRaport(raport, plic) { + const e = plic && plic.statement && plic.statement.evidenceHash; + if (!e) return { ok: false, reason: 'the envelope has no evidenceHash' }; + const h = sha256Canonic(raport); + return h === String(e).toLowerCase() ? { ok: true } : { ok: false, reason: 'the report is NOT the one bound to the envelope', fingerprint: h, inEnvelope: e }; +} +export { verificaRaport as verifyReport }; diff --git a/control-plane/remediere.mjs b/control-plane/remediere.mjs new file mode 100644 index 0000000..7330989 --- /dev/null +++ b/control-plane/remediere.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// remediere.mjs: remedierea actiunilor pe care produsele AERE NU le pot face singure (planul de migrare B1, metoda 'manual', si +// alternativa la gateway pentru expunerea HNDL): configuratia TLS/HTTP a serverului CLIENTULUI. Doua jumatati: +// 1. RETETA (recipe): pentru fiecare actiune, pe software-ul serverului (nginx, apache, haproxy, node, go), fragmentul exact de +// configuratie, preconditia masurabila (versiunea OpenSSL / Go si comanda care o arata) si comanda de verificare a configuratiei +// inainte de reincarcare. Fragmentul se GENEREAZA dintr-o singura forma structurata (grupuri, versiunea minima TLS, HSTS). +// 2. DOVADA (verify): dupa ce operatorul a aplicat reteta, rescanarea (acelasi scaner ca /v1/pq/readiness) judeca fiecare actiune: +// RESOLVED numai daca defectul lipseste SI proprietatea pozitiva e masurata, UNRESOLVED, sau UNMEASURED (scanare cazuta, alta +// gazda, masuratoare de dinainte de aplicare, proprietate nemasurabila). Fiecare judecata intra intr-un lant sha256(seq|prev| +// inregistrare), acelasi ca al executorului. +// Ce NU face, scris: nu se conecteaza la serverul clientului si nu ii scrie configuratia (o aplica operatorul); nu emite certificate. +// Ce e MASURAT de noi si ce nu, pe fiecare reteta (campul `measured`): vezi RETETE_MASURATE mai jos. Iesirea e in engleza (forma 2, +// 2026-09-29). +// +// node remediere.mjs recipe --plan plan.json --profile nginx|apache|haproxy|node|go [--json] +// node remediere.mjs verify --plan plan.json --scan after.json [--applied-at 2026-09-28T10:00:00Z] [--out dir] +// iesire: recipe 0; verify 0 = toate RESOLVED, 1 = cel putin una UNRESOLVED, 2 = cel putin una UNMEASURED (si niciuna nerezolvata) +import fs from 'node:fs'; +import path from 'node:path'; +import { CLASIFICARE_SCAN } from './plan-migrare.mjs'; +import { lantulExecutiei, verificaExecutie } from './executa-migrare.mjs'; + +export const VERSIUNE = 'aere-control-plane/remediation/2 (2026-09-29)'; +export const PROFILURI = ['nginx', 'apache', 'haproxy', 'node', 'go']; +const GRUPURI_PQ = ['X25519MLKEM768', 'X25519']; +const HSTS = 'max-age=31536000'; + +// ce masuratoare sustine fiecare profil (un mesaj nu afirma mai mult decat masuratoarea) +export const RETETE_MASURATE = { + node: 'measured end to end on 2026-09-28: a Node 24.14.1 / OpenSSL 3.5.5 server with the options of this recipe, scanned by the AERE scanner (proba-remediere.mjs)', + nginx: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the nginx documentation, not measured by us on a real nginx', + apache: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the mod_ssl documentation, not measured by us on a real Apache', + haproxy: 'the group names accepted by OpenSSL 3.5.5 (measured); the server directive from the HAProxy documentation, not measured by us on a real HAProxy', + go: 'from the Go 1.24 release notes (X25519MLKEM768 is the default when CurvePreferences is nil); not measured by us (the Go on the test machine is 1.22)', +}; + +// --- forma structurata: ce cere fiecare defect al scanerului -------------------------------------------------------------------- +// fiecare intrare: settings (groups | tlsMin | hsts), sau `operational` (certificatul: nu e o setare de server, e o reemitere) +export const REMEDIERI = { + 'hndl-exposed': { settings: { groups: GRUPURI_PQ }, requires: 'pq', reason: 'the hybrid group on your server, instead of (or before) the PQ Gateway' }, + 'pq-not-preferred': { settings: { groups: GRUPURI_PQ }, requires: 'pq', reason: 'the hybrid group FIRST in the server\'s group list' }, + 'tls13-missing': { settings: { tlsMin: '1.2' }, requires: 'tls13', reason: 'TLS 1.3 allowed (TLS 1.2 stays until you retire it separately)' }, + 'tls12-accepted': { settings: { tlsMin: '1.3' }, requires: null, reason: 'TLS 1.3 only; clients that know only TLS 1.2 can no longer connect (measure them first)' }, + 'hsts-missing': { settings: { hsts: HSTS }, requires: null, reason: 'HSTS for one year' }, + 'cert-expiring': { operational: 'renew now and check the automatic renewal', commands: ['certbot renew', 'systemctl list-timers | grep -i certbot'] }, + 'rsa-short': { operational: 'reissue with ECDSA P-256 (still classical: no public CA issues post-quantum certificates today)', commands: ['certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d '] }, + 'chain-untrusted': { operational: 'serve the complete chain (the leaf certificate and the intermediates), not only the leaf', commands: ['openssl s_client -connect :443 -servername -showcerts < /dev/null'] }, +}; + +// ref-ul actiunii -> id-ul defectului, DERIVAT din clasificatorul planificatorului (nu scris de mana): se cheama fiecare clasificare +// cu un domeniu marcat si se citeste forma ref-ului rezultat +const MARCA = 'domeniu.proba.invalid'; +export const REF_LA_ID = (() => { + const m = []; + for (const id of Object.keys(CLASIFICARE_SCAN)) { + const a = CLASIFICARE_SCAN[id]({ id }, MARCA); + if (a && a.ref) m.push({ id, prefix: a.ref.split(MARCA)[0], sufix: a.ref.split(MARCA)[1] || '' }); + } + return m; +})(); +// 2026-09-29, revizuirea adversariala (pista B, inainte de publicare), R1: domeniul iese din ref-ul planului (un fisier) si intra in +// comenzi de copiat in terminal (`certbot ... -d `, `openssl s_client -connect :443`); un "domeniu" ca +// `x.com; comanda` facea din reteta o comanda straina. Numai un nume de gazda (litere, cifre, cratima, puncte) primeste reteta. +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; +export const domeniuValid = (d) => typeof d === 'string' && NUME_GAZDA.test(d); +export function defectDinRef(ref) { + for (const r of REF_LA_ID) { + if (ref.startsWith(r.prefix) && ref.endsWith(r.sufix) && ref.length > r.prefix.length + r.sufix.length) { + return { id: r.id, domain: ref.slice(r.prefix.length, ref.length - r.sufix.length) }; + } + } + return null; +} + +// --- generatoarele, cate unul pe profil, din aceeasi forma -------------------------------------------------------------------- +const grup = (g) => g.join(':'); +const GENERATOARE = { + nginx: (s) => ({ + where: 'in the server { listen 443 ssl; ... } block of the domain', + fragment: [ + s.groups && `ssl_ecdh_curve ${grup(s.groups)};`, + s.tlsMin === '1.3' && 'ssl_protocols TLSv1.3;', + s.tlsMin === '1.2' && 'ssl_protocols TLSv1.2 TLSv1.3;', + s.hsts && `add_header Strict-Transport-Security "${s.hsts}" always;`, + ].filter(Boolean), + precondition: s.groups ? { what: 'nginx linked to OpenSSL 3.5 or later (both "built with" and "running with")', command: 'nginx -V 2>&1 | grep -i openssl' } : null, + verify: 'nginx -t', reload: 'systemctl reload nginx', + }), + apache: (s) => ({ + where: 'in the of the domain (HSTS needs mod_headers)', + fragment: [ + s.groups && `SSLOpenSSLConfCmd Groups ${grup(s.groups)}`, + s.tlsMin === '1.3' && 'SSLProtocol -all +TLSv1.3', + s.tlsMin === '1.2' && 'SSLProtocol -all +TLSv1.2 +TLSv1.3', + s.hsts && `Header always set Strict-Transport-Security "${s.hsts}"`, + ].filter(Boolean), + precondition: s.groups ? { what: 'mod_ssl linked to OpenSSL 3.5 or later (the startup line in error.log names the version)', command: 'grep -i "openssl/" /var/log/apache2/error.log | tail -1' } : null, + verify: 'apachectl configtest', reload: 'systemctl reload apache2', + }), + haproxy: (s) => ({ + where: 'the groups and the version in the global section; HSTS in the HTTPS frontend', + fragment: [ + s.groups && `ssl-default-bind-curves ${grup(s.groups)}`, + s.tlsMin && `ssl-default-bind-options ssl-min-ver TLSv${s.tlsMin}`, + s.hsts && `http-response set-header Strict-Transport-Security "${s.hsts}"`, + ].filter(Boolean), + precondition: s.groups ? { what: 'HAProxy running on OpenSSL 3.5 or later', command: 'haproxy -vv | grep -i "running on openssl"' } : null, + verify: 'haproxy -c -f /etc/haproxy/haproxy.cfg', reload: 'systemctl reload haproxy', + }), + node: (s) => { + const opt = {}; + if (s.groups) opt.ecdhCurve = grup(s.groups); + if (s.tlsMin) opt.minVersion = `TLSv${s.tlsMin}`; + return { + where: 'in the options of https.createServer / tls.createServer', + options: opt, + fragment: [ + Object.keys(opt).length ? `https.createServer({ ...yourOptions, ${Object.entries(opt).map(([k, v]) => `${k}: '${v}'`).join(', ')} }, app)` : null, + s.hsts && `res.setHeader('Strict-Transport-Security', '${s.hsts}')`, + ].filter(Boolean), + headers: s.hsts ? { 'strict-transport-security': s.hsts } : {}, + precondition: s.groups ? { what: 'Node linked to OpenSSL 3.5 or later', command: 'node -p process.versions.openssl' } : null, + verify: 'node --check ', reload: 'restart the process', + }; + }, + go: (s) => ({ + where: 'in the tls.Config of the server (crypto/tls)', + fragment: [ + (s.groups || s.tlsMin) && `&tls.Config{${[s.tlsMin && `MinVersion: tls.VersionTLS1${s.tlsMin === '1.3' ? '3' : '2'}`, s.groups && `CurvePreferences: []tls.CurveID{${s.groups.map((g) => 'tls.' + g).join(', ')}}`].filter(Boolean).join(', ')}}`, + s.groups && '// Go 1.24 or later: with CurvePreferences nil, X25519MLKEM768 is already the default; if you set it, put it first. GODEBUG=tlsmlkem=0 turns it off.', + s.hsts && `w.Header().Set("Strict-Transport-Security", "${s.hsts}")`, + ].filter(Boolean), + precondition: s.groups ? { what: 'the binary built with Go 1.24 or later and without GODEBUG=tlsmlkem=0', command: 'go version ' } : null, + verify: 'go vet ./...', reload: 'rebuild and restart', + }), +}; + +// uneste setarile mai multor actiuni ale aceluiasi domeniu (grupurile o data, versiunea minima cea mai stricta ceruta) +function uneste(lista) { + const s = {}; + for (const x of lista) { + if (x.groups) s.groups = x.groups; + if (x.tlsMin) s.tlsMin = s.tlsMin === '1.3' || x.tlsMin === '1.3' ? '1.3' : '1.2'; + if (x.hsts) s.hsts = x.hsts; + } + return s; +} + +/** reteta(plan, profil) -> { version, profile, measured, domains: [{ domain, actions, settings, config, operational }], none } */ +export function reteta(plan, profil) { + if (!Object.hasOwn(GENERATOARE, profil)) throw new Error(`unknown profile: ${profil} (${PROFILURI.join(', ')})`); + const peDomeniu = new Map(); const fara = []; + for (const a of plan.actions || []) { + const d = defectDinRef(String(a.ref || '')); + if (d && Object.hasOwn(REMEDIERI, d.id) && !domeniuValid(d.domain)) { fara.push({ ref: a.ref, reason: 'the domain in the ref is not a valid host name: no configuration and no command is generated with it' }); continue; } + if (!d || !Object.hasOwn(REMEDIERI, d.id)) { if (a.method === 'manual') fara.push({ ref: a.ref, reason: 'an action from the code inventory: it is fixed in the code (see `how`), not in the server configuration' }); continue; } + if (d.id === 'hndl-exposed' && a.method !== 'auto-aere' && a.method !== 'manual') continue; + const x = peDomeniu.get(d.domain) || { domain: d.domain, actions: [], settings: [], operational: [] }; + const r = REMEDIERI[d.id]; + x.actions.push({ ref: a.ref, defect: d.id, reason: r.reason || r.operational }); + if (r.settings) x.settings.push(r.settings); + if (r.operational) x.operational.push({ ref: a.ref, what: r.operational, commands: r.commands.map((c) => c.replaceAll('', d.domain)) }); + peDomeniu.set(d.domain, x); + } + const domenii = [...peDomeniu.values()].map((x) => { + const setari = uneste(x.settings); + return { domain: x.domain, actions: x.actions, settings: setari, config: Object.keys(setari).length ? GENERATOARE[profil](setari) : null, operational: x.operational }; + }); + return { version: VERSIUNE, profile: profil, measured: RETETE_MASURATE[profil], domains: domenii, none: fara }; +} + +// --- dovada: judecata pe rescanare ------------------------------------------------------------------------------------------------ +// proprietatea POZITIVA ceruta pe raportul de dupa, pe fiecare defect; null = nemasurabila din acest raport +const POZITIV = { + 'hndl-exposed': (r) => r.summary?.pqKeyExchange ? true : false, + 'pq-not-preferred': (r) => !r.summary?.pqKeyExchange ? false : (r.summary.prefersPqWhenOffered === true ? true : (r.summary.prefersPqWhenOffered === false ? false : null)), + 'tls13-missing': (r) => r.summary?.tls13 === true, + 'tls12-accepted': (r) => r.summary?.tls12Accepted === false, + 'hsts-missing': (r) => (r.summary?.hsts == null ? null : r.summary.hsts === true), + 'cert-expiring': (r) => (r.summary?.certificate?.daysLeft == null ? null : r.summary.certificate.daysLeft >= 30), + 'rsa-short': (r) => { const c = r.summary?.certificate; if (!c) return null; return !(c.keyType === 'RSA' && c.bits && c.bits < 3072); }, + 'chain-untrusted': (r) => (r.handshakes?.classicalBaseline?.ok ? r.handshakes.classicalBaseline.authorized === true : null), +}; + +/** verifica(plan, scanDupa, { appliedAt }) -> { summary, results, records } */ +export function verifica(plan, scan, o = {}) { + const lant = lantulExecutiei(); + lant.adauga({ type: 'start', version: VERSIUNE, at: o.at || new Date().toISOString(), domain: scan?.domain || null, measuredAt: scan?.measuredAt || null, appliedAt: o.appliedAt || null }); + const rezultate = []; const sumar = { RESOLVED: 0, UNRESOLVED: 0, UNMEASURED: 0 }; + // R2 (2026-09-29): fara o margine de timp, orice scanare (si una de acum o luna) putea dovedi RESOLVED. Marginea e momentul aplicarii + // dat de operator, altfel momentul generarii planului; fara niciuna, judecata e UNMEASURED, nu un verde pe o scanare de oricand. + const limita = o.appliedAt || plan.generatedAt || null; + const deCe = o.appliedAt ? 'the application' : 'the plan was generated'; + for (const a of plan.actions || []) { + const d = defectDinRef(String(a.ref || '')); + if (!d || !Object.hasOwn(POZITIV, d.id)) continue; + let stare, motiv; + if (!scan || scan.error) { stare = 'UNMEASURED'; motiv = `the scan after failed (${scan?.error || 'missing'})`; } + else if (scan.domain !== d.domain) { stare = 'UNMEASURED'; motiv = `the scan is of ${scan.domain}, the action is of ${d.domain}`; } + else if (!limita || !Number.isFinite(Date.parse(limita))) { stare = 'UNMEASURED'; motiv = 'no time of application (--applied-at) and no generatedAt in the plan: a scan from any time would pass as proof'; } + else if (!(Date.parse(scan.measuredAt) > Date.parse(limita))) { stare = 'UNMEASURED'; motiv = `the scan (${scan.measuredAt}) is not from after ${deCe} (${limita})`; } + else { + const inca = (scan.findings || []).some((f) => f.id === d.id); + const poz = POZITIV[d.id](scan); + if (inca || poz === false) { stare = 'UNRESOLVED'; motiv = inca ? `the scanner still reports ${d.id}` : `the defect is gone, but the required property is missing (${d.id})`; } + else if (poz === null) { stare = 'UNMEASURED'; motiv = `the property required by ${d.id} cannot be measured from the report`; } + else { stare = 'RESOLVED'; motiv = `${d.id} is absent and the required property is measured`; } + } + sumar[stare]++; + const rec = { ref: a.ref, defect: d.id, domain: d.domain, state: stare, reason: motiv }; + rezultate.push(rec); lant.adauga(rec); + } + lant.adauga({ type: 'end', summary: sumar }); + return { summary: sumar, results: rezultate, records: lant.lista() }; +} +export { verificaExecutie as verificaRemedierea, reteta as recipe, verifica as verifyRemediation }; + +// --- CLI ------------------------------------------------------------------------------------------------------------------------- +const RULAT_DIRECT = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/remediere.mjs'); +if (RULAT_DIRECT) { + const arg = (n) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : undefined; }; + const cmd = process.argv[2]; + try { + if (cmd !== 'recipe' && cmd !== 'verify') { console.log('usage: node remediere.mjs recipe --plan p.json --profile nginx | verify --plan p.json --scan s.json [--applied-at T] [--out dir]'); process.exitCode = 2; } + else { + const plan = JSON.parse(fs.readFileSync(arg('--plan'), 'utf8')); + if (cmd === 'recipe') { + const r = reteta(plan, arg('--profile')); + if (process.argv.includes('--json')) console.log(JSON.stringify(r, null, 2)); + else { + console.log(`recipe for ${r.profile} (${r.measured})`); + for (const d of r.domains) { + console.log(`\n== ${d.domain}: ${d.actions.map((a) => a.defect).join(', ')}`); + if (d.config) { + if (d.config.precondition) console.log(` precondition: ${d.config.precondition.what}\n ${d.config.precondition.command}`); + console.log(` ${d.config.where}:`); for (const l of d.config.fragment) console.log(` ${l}`); + console.log(` before reloading: ${d.config.verify}\n then: ${d.config.reload}`); + } + for (const x of d.operational) { console.log(` ${x.what}:`); for (const c of x.commands) console.log(` ${c}`); } + } + for (const f of r.none) console.log(`\nno server recipe: ${f.ref} (${f.reason})`); + console.log('\nafter applying it: rescan the domain and run `node remediere.mjs verify --plan ... --scan ...`'); + } + process.exitCode = 0; + } else { + const scan = JSON.parse(fs.readFileSync(arg('--scan'), 'utf8')); + const r = verifica(plan, scan, { appliedAt: arg('--applied-at') }); + for (const x of r.results) console.log(` ${x.state.padEnd(10)} ${x.ref}: ${x.reason}`); + console.log(`RESOLVED ${r.summary.RESOLVED} | UNRESOLVED ${r.summary.UNRESOLVED} | UNMEASURED ${r.summary.UNMEASURED}`); + if (arg('--out')) { fs.mkdirSync(arg('--out'), { recursive: true }); fs.writeFileSync(path.join(arg('--out'), 'remediation.json'), JSON.stringify({ version: VERSIUNE, records: r.records }, null, 1) + '\n'); } + process.exitCode = r.summary.UNRESOLVED ? 1 : (r.summary.UNMEASURED ? 2 : 0); + } + } + } catch (e) { console.error(`UNMEASURED: ${String(e.message || e).slice(0, 200)}`); process.exitCode = 2; } +} diff --git a/readiness/README.md b/readiness/README.md new file mode 100644 index 0000000..9bd0aaf --- /dev/null +++ b/readiness/README.md @@ -0,0 +1,49 @@ +# Post-quantum readiness scanner + +`readiness-service.mjs` measures, for one public hostname, whether its TLS edge is ready for post-quantum key exchange. It does not +estimate: it opens real connections and reports what the server did. + +1. a TLS 1.3 handshake with the usual classical groups: the baseline (protocol, cipher, group, the certificate and its chain); +2. a TLS 1.3 handshake offering **only** `X25519MLKEM768` (then `SecP256r1MLKEM768`): does the server know the hybrid key exchange? +3. a TLS 1.3 handshake offering the hybrid group first, with classical fallbacks: does it **prefer** it? (OpenSSL does not name the + hybrid group in the ephemeral key information, so the preference is inferred; the method control is that the classical baseline + comes back with a named group, and without it no claim about preference is made); +4. a TLS 1.2-only handshake: is a version without post-quantum key exchange still accepted? +5. an HTTPS `HEAD /` for HSTS. + +From these it reports the harvest-now-decrypt-later exposure (no post-quantum key exchange: traffic recorded today can be read once a +cryptographically relevant quantum computer exists), classical certificate authentication (reported, not penalized: no public CA +issues post-quantum certificates yet), expiry, TLS 1.2, HSTS, a score and concrete recommendations. Every finding has a stable `id`; +the control plane's planner classifies exactly these ids, and its test derives them from this file. + +It needs Node.js 24 with OpenSSL 3.5 or later (the ML-KEM groups) and refuses to start otherwise, instead of reporting "no +post-quantum" about everyone. + +## As a service + + PORT=8797 AERE_READINESS_FROM="your label" AERE_READINESS_DIR=/var/lib/aere/readiness node readiness-service.mjs + curl -s -H 'content-type: application/json' -d '{"domain":"example.com"}' http://127.0.0.1:8797/scan + +It listens on 127.0.0.1 only; put your own proxy in front. Endpoints: `POST /scan {"domain": ..., "fresh": true?}`, `GET /scan/` +(the cached report), `GET /health`. Reports are cached for six hours; a failed scan is not cached. + +- **No connection to anything that is not proven public.** A hostname that resolves, even partly, to a loopback, private, link-local, + CGNAT, documentation, multicast or reserved address (IPv4 or IPv6, including mapped and NAT64 forms) gets no connection, and the + answer does not name its addresses. The connections then go to the address that was checked, so the name is not resolved a second + time between the check and the connection (`adrese-private.mjs`). +- **Rate limit per client:** at most 12 scans per minute. The client is the value of the `x-aere-client` header set by the front that + calls the service (it listens on 127.0.0.1, so only something on the same host can set it), otherwise the connecting address. + `X-Forwarded-For` is never read: its first element is chosen by the client, and a limit keyed on it could be bypassed by changing it + (fixed on 2026-09-29). +- **Bounded work:** at most 4 scans at a time and 32 waiting; past that the answer is `503 busy`, not an unbounded wait. + +`scaneaza(domain)` and `scaneazaAdresa(domain, address, { port })` can also be imported as functions; the second one has no +private-address guard (it is for callers that checked the target themselves, such as local tests on 127.0.0.1). + +## Tests + + node proba-adrese-private.mjs # the private-address rules, and a local listener that must never be touched by a scan + +The control plane's tests (`../control-plane/`) also run this scanner against real TLS servers on 127.0.0.1. The rate limit and the +queue bound are tested by the service that runs it in production (`proba-client-real.mjs` in the Aere Network repository, not +included here); here they are documented, not measured. No third party has reviewed it. diff --git a/readiness/adrese-private.mjs b/readiness/adrese-private.mjs new file mode 100644 index 0000000..7776066 --- /dev/null +++ b/readiness/adrese-private.mjs @@ -0,0 +1,62 @@ +// O adresa IP la care un serviciu PUBLIC al nostru nu are voie sa se conecteze in numele unui strain: loopback, retele private, +// link-local, CGNAT, adrese de documentatie si de test, multicast, rezervate, si formele IPv6 ale acelorasi (inclusiv IPv4 mapat +// si NAT64). UN singur loc, folosit de gateway (tintele webhook-urilor) si de scanerul de pregatire post-cuantica: doua copii ale +// aceleiasi liste diverg, iar cea din gateway nu stia de CGNAT (100.64/10), de IPv4 mapat in IPv6 privat si de `::`. +// +// Scris 2026-09-18, dupa ce scanerul PUBLIC, fara cheie, a fost prins (masurat pe serviciul viu) rezolvand o gazda straina la +// 127.0.0.1 si ::1 si INCERCAND conexiunea: oricine isi putea indrepta un nume spre o adresa interna si afla din raspuns daca +// portul 443 e deschis acolo si ce certificat poarta. Regula: orice nu e DOVEDIT public e privat (o adresa pe care nu o inteleg +// nu primeste conexiune). +const v4 = (ip) => { const p = ip.split('.'); if (p.length !== 4) return null; const n = p.map((x) => (/^\d{1,3}$/.test(x) ? Number(x) : NaN)); return n.every((x) => x >= 0 && x <= 255) ? n : null; }; + +function v4Privat([a, b, c]) { + return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2) || (a === 192 && b === 88 && c === 99) || + (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113) || a >= 224; +} + +// IPv6 desfacut in 8 grupuri de 16 biti; null daca textul nu e o adresa IPv6 +function v6Grupuri(ip) { + let t = ip.toLowerCase(); const zona = t.indexOf('%'); if (zona >= 0) t = t.slice(0, zona); + let coada4 = null; + const m = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(t); + if (m) { coada4 = v4(m[2]); if (!coada4) return null; t = m[1] + ((coada4[0] << 8) | coada4[1]).toString(16) + ':' + ((coada4[2] << 8) | coada4[3]).toString(16); } + if ((t.match(/::/g) || []).length > 1) return null; + const [st, dr] = t.includes('::') ? t.split('::') : [t, null]; + const a = st ? st.split(':') : [], b = dr === null ? [] : (dr ? dr.split(':') : []); + if (dr === null ? a.length !== 8 : a.length + b.length > 7) return null; + const g = [...a, ...Array(8 - a.length - b.length).fill('0'), ...b]; + if (!g.every((x) => /^[0-9a-f]{1,4}$/.test(x))) return null; + return g.map((x) => parseInt(x, 16)); +} + +export function ipPrivat(ip) { + const text = String(ip || '').trim(); + const p4 = v4(text); + if (p4) return v4Privat(p4); + const g = v6Grupuri(text); + if (!g) return true; // nu e o adresa pe care o inteleg: nu primeste conexiune + const ultimele4 = [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255]; + if (g.slice(0, 5).every((x) => x === 0)) { + if (g[5] === 0xffff) return v4Privat(ultimele4); // ::ffff:a.b.c.d, IPv4 mapat: il judeca regula IPv4 + if (g[5] === 0) return true; // ::, ::1 si vechiul ::a.b.c.d (retras): nimic public acolo + } + if (g[0] === 0x64 && g[1] === 0xff9b) return v4Privat(ultimele4); // NAT64 64:ff9b::/96 (si 64:ff9b:1::/48) + if (g[0] === 0x2002) return v4Privat([g[1] >> 8, g[1] & 255, g[2] >> 8, g[2] & 255]); // 6to4 poarta un IPv4 in el + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentatie + if (g[0] === 0x3fff && (g[1] & 0xf000) === 0) return true; // 3fff::/20, documentatie (RFC 9637); prins de proba, nu de mine + if (g[0] === 0x2001 && g[1] === 0) return true; // Teredo + if ((g[0] & 0xe000) !== 0x2000) return true; // in afara 2000::/3 (unicast global) nu e public + return false; +} + +// Un `lookup` pentru tls.connect / https.request care intoarce NUMAI adresa deja verificata: intre verificare si conexiune +// numele nu mai e rezolvat a doua oara, deci un DNS care isi schimba raspunsul (rebinding) nu mai are ce sa mute. +export const lookupFixat = (adresa) => (gazda, optiuni, cb) => { + if (typeof optiuni === 'function') { cb = optiuni; optiuni = {}; } + if (optiuni && optiuni.all) cb(null, [{ address: adresa.address, family: adresa.family }]); + else cb(null, adresa.address, adresa.family); +}; diff --git a/readiness/proba-adrese-private.mjs b/readiness/proba-adrese-private.mjs new file mode 100644 index 0000000..9a26fe6 --- /dev/null +++ b/readiness/proba-adrese-private.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Probele gardei de adrese private (adrese-private.mjs) si ale scanerului care o foloseste. Offline: rezolvarea DNS se +// injecteaza, iar "conexiunea" se masoara pe un server TLS local care NUMARA cine il atinge. +// node aerenew/cloud-gateway/proba-adrese-private.mjs +import assert from 'node:assert'; +import net from 'node:net'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +process.env.AERE_READINESS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-readiness-')); +const { ipPrivat, lookupFixat } = await import('./adrese-private.mjs'); +const { scaneaza } = await import('./readiness-service.mjs'); + +let treceri = 0; const esecuri = []; +async function test(nume, fn) { try { await fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' ESEC ' + nume + ' -> ' + String(e.message || e).slice(0, 260)); } } + +const PRIVATE = ['127.0.0.1', '127.255.255.254', '10.10.0.23', '10.0.0.1', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '100.127.255.255', // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + '0.0.0.0', '192.0.0.1', '192.0.2.5', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9', '224.0.0.1', '255.255.255.255', // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + '::1', '::', '::ffff:127.0.0.1', '::ffff:10.10.0.23', '::ffff:a0a:17', '::10.0.0.1', 'fe80::1', 'fe80::1%eth0', 'fc00::1', 'fd12:3456::1', 'ff02::1', '2001:db8::1', '2001::1', // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + '64:ff9b::10.0.0.1', '64:ff9b::7f00:1', '2002:0a0a:0017::1', '3fff::1', '4000::1', 'nu-e-adresa', '', '1.2.3', '1.2.3.4.5', '256.1.1.1', '12345::1', ':::1']; // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde +const PUBLICE = ['1.1.1.1', '8.8.8.8', '172.15.255.255', '172.32.0.1', '100.63.255.255', '100.128.0.1', '192.167.255.255', '169.253.0.1', '198.17.255.255', '198.20.0.1', '223.255.255.255', // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + '2606:4700:4700::1111', '2a01:4f8::1', '::ffff:8.8.8.8', '64:ff9b::8.8.8.8', '2002:0808:0808::1']; // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + +await test(`predicatul: ${PRIVATE.length} adrese care NU primesc conexiune (loopback, private, CGNAT, link-local, documentatie, multicast, formele lor IPv6, si orice text care nu e adresa)`, () => { + const scapate = PRIVATE.filter((a) => !ipPrivat(a)); assert.deepStrictEqual(scapate, []); +}); +await test(`predicatul: ${PUBLICE.length} adrese publice, inclusiv vecinele de granita ale fiecarui bloc privat, NU sunt refuzate`, () => { + const refuzate = PUBLICE.filter((a) => ipPrivat(a)); assert.deepStrictEqual(refuzate, []); +}); +await test('regula veche a gateway-ului lasa sa treaca exact ce s-a adaugat (CGNAT, IPv4 privat mapat, ::): proba deosebeste cele doua reguli', () => { + const veche = (ip) => { if (ip.includes(':')) return ip === '::1' || ip.startsWith('fe80') || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('::ffff:127.'); const [a, b] = ip.split('.').map(Number); return a === 127 || a === 10 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254); }; + for (const a of ['100.64.0.1', '::ffff:10.10.0.23', '::', '198.18.0.1', '64:ff9b::10.0.0.1']) { assert.strictEqual(veche(a), false, a + ': regula veche o lasa'); assert.strictEqual(ipPrivat(a), true, a); } // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde +}); + +// un ascultator local care numara conexiunile: daca scanerul il atinge, garda nu a tinut +let atingeri = 0; const srv = net.createServer((s) => { atingeri++; s.destroy(); }); +await new Promise((r) => srv.listen(0, '127.0.0.1', r)); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + +await test('scanerul: o gazda rezolvata la o adresa privata -> private_target, FARA adrese in raspuns si fara nicio conexiune', async () => { + for (const adrese of [[{ address: '10.10.0.23', family: 4 }], [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }], [{ address: '1.1.1.1', family: 4 }, { address: '169.254.169.254', family: 4 }]]) { // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + const t0 = Date.now(); const r = await scaneaza('intern.example', { rezolva: async () => adrese }); + assert.strictEqual(r.error, 'private_target', JSON.stringify(r)); assert.strictEqual(r.addresses, undefined, 'raspunsul nu are voie sa spuna adresele'); assert.doesNotMatch(JSON.stringify(r), /10\.10\.0\.23|169\.254|127\.0\.0\.1/); + assert.ok(Date.now() - t0 < 1000, 'a durat ' + (Date.now() - t0) + ' ms: a incercat o conexiune'); + } + assert.strictEqual(atingeri, 0); +}); +await test('scanerul: o rezolvare goala sau cazuta e eroare de DNS, nu o scanare', async () => { + assert.strictEqual((await scaneaza('gol.example', { rezolva: async () => [] })).error, 'dns'); + assert.strictEqual((await scaneaza('cade.example', { rezolva: async () => { const e = new Error('x'); e.code = 'ENOTFOUND'; throw e; } })).detail, 'ENOTFOUND'); +}); +await test('lookupFixat: conexiunea merge pe adresa VERIFICATA, in amandoua formele de apel (cu si fara all), oricare ar fi numele', async () => { + const l = lookupFixat({ address: '1.1.1.1', family: 4 }); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + await new Promise((ok, rau) => l('se-schimba.example', { all: true }, (e, v) => { try { assert.deepStrictEqual(v, [{ address: '1.1.1.1', family: 4 }]); ok(); } catch (x) { rau(x); } })); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + await new Promise((ok, rau) => l('se-schimba.example', {}, (e, a, f) => { try { assert.deepStrictEqual([a, f], ['1.1.1.1', 4]); ok(); } catch (x) { rau(x); } })); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + await new Promise((ok, rau) => l('se-schimba.example', (e, a, f) => { try { assert.deepStrictEqual([a, f], ['1.1.1.1', 4]); ok(); } catch (x) { rau(x); } })); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + // si chiar foloseste adresa: o conexiune net cu lookup fixat pe ascultatorul local ajunge la el, desi numele nu exista + const inainte = atingeri; + await new Promise((ok) => { const s = net.connect({ host: 'nume-care-nu-exista.invalid', port: srv.address().port, lookup: lookupFixat({ address: '127.0.0.1', family: 4 }) }); s.on('close', ok); s.on('error', ok); }); // AERE-SINTETIC: adrese de granita ale regulii (vectori de proba), nu gazde + assert.strictEqual(atingeri, inainte + 1, 'lookup-ul fixat nu a fost folosit'); +}); +srv.close(); +fs.rmSync(process.env.AERE_READINESS_DIR, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/readiness/readiness-service.mjs b/readiness/readiness-service.mjs new file mode 100644 index 0000000..098a7ef --- /dev/null +++ b/readiness/readiness-service.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// AERE Quantum Readiness: scanerul public al pregatirii post-cuantice a unui domeniu. MASOARA, nu estimeaza: +// pentru gazda data, patru strangeri de mana TLS reale de pe gazda Cloud (EU) si o cerere HEAD: +// 1. TLS 1.3 cu grupurile obisnuite -> linia de baza: protocol, cifru, grupul clasic, certificatul si lantul lui +// 2. TLS 1.3 oferind NUMAI X25519MLKEM768 -> serverul stie schimbul de chei hibrid post-cuantic? (da/nu) +// 3. TLS 1.3 cu hibridul oferit primul + clasice -> il PREFERA cand i se ofera? (dedus: OpenSSL nu numeste grupul hibrid +// in getEphemeralKeyInfo, deci "reusit si fara nume" = ne-clasic; controlul +// pozitiv al metodei e ca grupurile clasice IES cu nume) +// 4. numai TLS 1.2 -> mai accepta o versiune fara schimb de chei post-cuantic? +// 5. HEAD https://gazda/ -> HSTS +// De aici: expunerea la "recolteaza acum, decripteaza mai tarziu" (fara schimb de chei PQ, tot traficul inregistrat azi se +// poate citi cand exista un calculator cuantic), autentificarea clasica a certificatului (azi TOATE certificatele WebPKI +// sunt ECDSA/RSA: se raporteaza, nu se penalizeaza, fiindca nu exista inca alternativa emisa de CA-uri), expirarea, +// TLS 1.2, HSTS; un scor si recomandari concrete. Cere Node cu OpenSSL >= 3.5 (grupurile ML-KEM); altfel refuza sa +// porneasca, in loc sa raporteze "fara PQ" despre toata lumea. +import http from 'node:http'; +import tls from 'node:tls'; +import dns from 'node:dns/promises'; +import https from 'node:https'; +import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { ipPrivat, lookupFixat } from './adrese-private.mjs'; + +const PORT = Number(process.env.PORT || 8797); +const FROM = process.env.AERE_READINESS_FROM || 'AERE Cloud, EU'; +const JURNAL_DIR = process.env.AERE_READINESS_DIR || '/var/lib/aere/readiness'; +const ADOPTIE_DIR = process.env.AERE_ADOPTIE_DIR || '/var/lib/aere/readiness/adoptie'; +const TTL_MS = 6 * 3600e3; +const PQ_GROUP = 'X25519MLKEM768'; +const PQ_GROUP_2 = 'SecP256r1MLKEM768'; + +const [oMaj, oMin] = String(process.versions.openssl).split('.').map(Number); +if (oMaj < 3 || (oMaj === 3 && oMin < 5)) { + console.error(`REFUSED: OpenSSL ${process.versions.openssl} does not know the ML-KEM groups; 3.5 or later is required`); + process.exit(2); +} +try { mkdirSync(JURNAL_DIR, { recursive: true }); } catch {} + +const cache = new Map(); // domeniu -> { at, report } +const ritm = new Map(); // ip -> [timestamps] +// B-16 (2026-09-29): coada de asteptare e MARGINITA; fara margine, cereri trimise mai repede decat se scaneaza tineau fiecare o +// conexiune si memorie la nesfarsit. Peste margine raspunsul e 503 busy, spus, nu o asteptare fara capat. +let inLucru = 0; const MAX_PARALEL = 4; const coada = []; export const MAX_COADA = 32; + +const domeniuValid = (d) => /^(?=.{1,253}$)(?!-)([a-z0-9-]{1,63}\.)+[a-z]{2,63}$/i.test(d) && !/^\d+\.\d+\.\d+\.\d+$/.test(d); + +function probe(host, opts, adresa, port = 443) { + return new Promise((res) => { + const t0 = Date.now(); + let done = false; + const fin = (v) => { if (!done) { done = true; res(v); } }; + let s; + try { + s = tls.connect({ host, port, servername: host, timeout: 8000, ALPNProtocols: ['h2', 'http/1.1'], rejectUnauthorized: false, ...(adresa ? { lookup: lookupFixat(adresa) } : {}), ...opts }, () => { + const c = s.getPeerCertificate(true) || {}; + const e = s.getEphemeralKeyInfo() || {}; + const chain = []; let x = c; const seen = new Set(); + while (x && x.fingerprint256 && !seen.has(x.fingerprint256)) { seen.add(x.fingerprint256); chain.push({ subject: x.subject?.CN || null, issuer: x.issuer?.O || x.issuer?.CN || null, keyType: x.asn1Curve ? 'EC/' + x.asn1Curve : (x.bits ? 'RSA' : 'other'), bits: x.bits || null, validTo: x.valid_to || null }); if (!x.issuerCertificate || x.issuerCertificate === x) break; x = x.issuerCertificate; } + fin({ ok: true, ms: Date.now() - t0, protocol: s.getProtocol(), cipher: s.getCipher()?.name || null, groupName: e.name || null, alpn: s.alpnProtocol || null, authorized: s.authorized, authError: s.authorized ? null : (s.authorizationError ? String(s.authorizationError) : null), chain }); + s.end(); + }); + } catch (e) { return fin({ ok: false, ms: Date.now() - t0, err: e.code || e.message }); } + s.on('error', (e) => fin({ ok: false, ms: Date.now() - t0, err: e.code || String(e.message).slice(0, 80) })); + s.on('timeout', () => { s.destroy(); fin({ ok: false, ms: Date.now() - t0, err: 'timeout' }); }); + }); +} + +function head(host, adresa, port = 443) { + return new Promise((res) => { + const t0 = Date.now(); + const req = https.request({ host, port, servername: host, path: '/', method: 'HEAD', timeout: 8000, ...(adresa ? { lookup: lookupFixat(adresa) } : {}), headers: { 'user-agent': 'aere-quantum-readiness/1 (+https://aere.network/quantum-readiness.html)' }, rejectUnauthorized: false }, (r) => { + res({ ok: true, status: r.statusCode, hsts: r.headers['strict-transport-security'] || null, ms: Date.now() - t0 }); r.resume(); + }); + req.on('error', (e) => res({ ok: false, err: e.code || String(e.message).slice(0, 60) })); req.on('timeout', () => { req.destroy(); res({ ok: false, err: 'timeout' }); }); req.end(); + }); +} + +export async function scaneaza(domain, { rezolva = (d) => dns.lookup(d, { all: true }) } = {}) { + const measuredAt = new Date().toISOString(); + let addrs; + try { addrs = await rezolva(domain); } catch (e) { return { domain, measuredAt, from: FROM, error: 'dns', detail: e.code || 'unresolvable' }; } + // GARDA (2026-09-18): scanerul e public si fara cheie. O gazda care se rezolva, fie si PARTIAL, la o adresa care nu e dovedit + // publica nu primeste NICIO conexiune si raspunsul nu ii spune adresele: altfel oricine isi indreapta un nume spre reteaua + // noastra interna si afla din raport ce porturi 443 sunt deschise acolo si ce certificate poarta (masurat pe serviciul viu: + // o gazda straina rezolvata la 127.0.0.1 si ::1 a fost sondata). Conexiunile merg apoi pe adresa VERIFICATA, nu pe nume: + // intre verificare si conexiune numele nu se mai rezolva a doua oara. + if (!Array.isArray(addrs) || !addrs.length) return { domain, measuredAt, from: FROM, error: 'dns', detail: 'unresolvable' }; + if (addrs.some((a) => ipPrivat(a.address))) return { domain, measuredAt, from: FROM, error: 'private_target', detail: 'the hostname resolves to an address that is not public; nothing was connected to' }; + const tinta = addrs.find((a) => a.family === 4) || addrs[0]; + return scaneazaAdresa(domain, tinta, { measuredAt, addrs }); +} + +// scaneazaAdresa: masuratoarea propriu-zisa pe o adresa DEJA verificata. NU are garda de adrese private: serviciul HTTP cheama numai +// `scaneaza` (de mai sus), care o pune; aceasta e pentru apelantii care au verificat singuri tinta si pentru probele locale ale +// remedierii (2026-09-28, control-plane/remediere.mjs: un server TLS pe 127.0.0.1, pe alt port decat 443, fara jurnalul serviciului). +export async function scaneazaAdresa(domain, tinta, { port = 443, measuredAt = new Date().toISOString(), addrs = [tinta], jurnal = true } = {}) { + // linia de baza cere EXPLICIT grupurile clasice: grupul implicit al lui OpenSSL 3.5 pune hibridul primul, deci fara + // lista explicita chiar linia de baza ar negocia ML-KEM si controlul metodei (grupul clasic are nume) ar cadea + const base = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: 'X25519:P-256:P-384' }, tinta, port); + const modern = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3' }, tinta, port); // ce primeste un client OpenSSL 3.5 la zi + const pq1 = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: PQ_GROUP }, tinta, port); + const pq2 = pq1.ok ? null : await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: PQ_GROUP_2 }, tinta, port); + const pref = await probe(domain, { minVersion: 'TLSv1.3', maxVersion: 'TLSv1.3', ecdhCurve: `${PQ_GROUP}:X25519:P-256` }, tinta, port); + const t12 = await probe(domain, { minVersion: 'TLSv1.2', maxVersion: 'TLSv1.2' }, tinta, port); + const h = await head(domain, tinta, port); + const tls13 = base.ok; + const pqKex = pq1.ok ? PQ_GROUP : (pq2 && pq2.ok ? PQ_GROUP_2 : null); + // controlul pozitiv al metodei de deducere: grupul clasic trebuie sa iasa CU nume; daca nu iese, deducerea nu e valida + const metodaOk = base.ok && typeof base.groupName === 'string' && base.groupName.length > 0; + const prefersPq = pqKex && pref.ok ? (metodaOk ? (pref.groupName === null) : null) : (pqKex ? false : null); + const leaf = (base.ok ? base : t12).chain?.[0] || null; + const daysLeft = leaf?.validTo ? Math.floor((Date.parse(leaf.validTo) - Date.now()) / 86400e3) : null; + const findings = []; let score = 100; + const add = (id, severity, title, detail, recommendation, penalty) => { findings.push({ id, severity, title, detail, recommendation }); score -= penalty; }; + if (!tls13 && !t12.ok) return { domain, measuredAt, from: FROM, error: 'no_tls', detail: base.err || t12.err, addresses: addrs.map((a) => a.address) }; + if (!tls13) add('tls13-missing', 'high', 'TLS 1.3 is not offered', `Only TLS 1.2 handshakes succeeded (${t12.err ? '' : t12.cipher}). Post-quantum key exchange exists only in TLS 1.3.`, 'Enable TLS 1.3 on the edge or load balancer; then enable a hybrid post-quantum group.', 15); + if (!pqKex) add('hndl-exposed', 'high', 'No post-quantum key exchange: harvest-now-decrypt-later exposure', `The server refused a TLS 1.3 handshake offering only ${PQ_GROUP} (${pq1.err || 'handshake failure'})${pq2 ? ` and ${PQ_GROUP_2} (${pq2.err || 'handshake failure'})` : ''}. Every session recorded today is decryptable once a cryptographically relevant quantum computer exists.`, `Enable the hybrid group ${PQ_GROUP} (X25519 + ML-KEM-768, FIPS 203). Cloudflare, Google, Amazon and modern OpenSSL/BoringSSL stacks support it; browsers already send it.`, 45); + else if (prefersPq === false) add('pq-not-preferred', 'medium', 'Post-quantum key exchange is supported but not preferred', `With ${PQ_GROUP} offered first alongside classical groups, the server picked ${pref.groupName || 'a classical group'}.`, 'Order the hybrid group first in the server preference list so every capable client gets it.', 10); + else if (prefersPq === null && pqKex) add('pq-preference-unmeasured', 'info', 'Post-quantum key exchange is supported; preference could not be inferred', 'The method control (a classical group must report its name) did not pass, so no claim is made about preference.', null, 0); + if (t12.ok) add('tls12-accepted', 'medium', 'TLS 1.2 is still accepted', `A TLS 1.2-only client was served (${t12.cipher}). Such sessions never get post-quantum key exchange.`, 'Retire TLS 1.2 once your client population allows it, or at least prefer TLS 1.3.', 10); + if (h.ok && !h.hsts) add('hsts-missing', 'low', 'No HSTS header', 'Strict-Transport-Security is absent on the front page, so a first visit can be downgraded to plaintext.', 'Send Strict-Transport-Security with a max-age of at least one year.', 5); + if (leaf) { + if (daysLeft !== null && daysLeft < 7) add('cert-expiring', 'high', 'Certificate expires in less than 7 days', `Leaf certificate valid to ${leaf.validTo}.`, 'Renew now and automate renewal.', 20); + else if (daysLeft !== null && daysLeft < 30) add('cert-expiring', 'medium', 'Certificate expires in less than 30 days', `Leaf certificate valid to ${leaf.validTo}.`, 'Automate renewal (ACME) so rotation never depends on a person.', 10); + if (/^RSA$/.test(leaf.keyType) && leaf.bits && leaf.bits < 3072) add('rsa-short', 'low', `RSA-${leaf.bits} leaf key`, 'Below the 3072-bit size recommended for keys living past 2030 (classical strength).', 'Move to ECDSA P-256/P-384 or RSA-3072+ at the next issuance.', 5); + add('auth-classical', 'info', `Certificate authentication is classical (${leaf.keyType}${leaf.bits ? '-' + leaf.bits : ''})`, 'Every public web certificate today is signed with ECDSA or RSA; a quantum adversary could forge such signatures in the future, but unlike encryption this cannot be exploited retroactively on recorded traffic.', 'Keep certificate agility: short-lived, automatically issued certificates, so switching to hybrid or post-quantum certificates is a configuration change when CAs offer them.', 0); + } + if (!base.authorized && base.ok) add('chain-untrusted', 'medium', 'Certificate chain not trusted by a standard root store', String(base.authError || ''), 'Serve the full intermediate chain from a publicly trusted CA.', 10); + score = Math.max(0, score); + const verdict = score >= 80 ? 'post-quantum key exchange in place' : score >= 50 ? 'partially prepared' : 'exposed'; + const report = { + domain, measuredAt, from: FROM, addresses: addrs.map((a) => a.address), score, verdict, + summary: { tls13, pqKeyExchange: pqKex, prefersPqWhenOffered: prefersPq, tls12Accepted: t12.ok, hsts: h.ok ? !!h.hsts : null, harvestNowDecryptLater: pqKex ? 'protected for TLS 1.3 clients that offer the hybrid group' : 'exposed', certificate: leaf ? { keyType: leaf.keyType, bits: leaf.bits, issuer: leaf.issuer, validTo: leaf.validTo, daysLeft } : null }, + handshakes: { classicalBaseline: base, modernClientDefault: modern, pqOnly: pq1, pqOnlyAlt: pq2, pqPreferredOffer: pref, tls12Only: t12, head: h }, + findings, + method: 'Five real connections from the AERE Cloud host (EU): a TLS 1.3 baseline; TLS 1.3 offering only X25519MLKEM768 (then SecP256r1MLKEM768); TLS 1.3 offering the hybrid group first with classical fallbacks (preference inferred, with a method control); TLS 1.2 only; and an HTTPS HEAD for HSTS. Certificate facts come from the served chain. This measures the public TLS edge of one hostname; it does not measure your applications, keys at rest, internal services or code, which a full quantum readiness assessment covers.', + }; + if (jurnal) try { appendFileSync(`${JURNAL_DIR}/scanari.jsonl`, JSON.stringify({ t: measuredAt, domain, score, pqKex: !!pqKex }) + '\n'); } catch {} + return report; +} + +function ritmOk(ip) { + const now = Date.now(); const l = (ritm.get(ip) || []).filter((t) => now - t < 60e3); + if (l.length >= 12) { ritm.set(ip, l); return false; } + l.push(now); ritm.set(ip, l); return true; +} +function json(res, cod, obj) { const b = JSON.stringify(obj); res.writeHead(cod, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(b), 'cache-control': 'no-store' }); res.end(b); } +const corp = (req) => new Promise((res, rej) => { let s = ''; req.on('data', (d) => { s += d; if (s.length > 4096) { rej(new Error('too_big')); req.destroy(); } }); req.on('end', () => res(s)); req.on('error', rej); }); + +export async function cuLimita(fn) { + if (inLucru >= MAX_PARALEL) { + if (coada.length >= MAX_COADA) { const e = new Error('busy'); e.busy = true; throw e; } + await new Promise((r) => coada.push(r)); + } + inLucru++; + try { return await fn(); } finally { inLucru--; const n = coada.shift(); if (n) n(); } +} + +const RULAT_DIRECT = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/readiness-service.mjs'); +if (RULAT_DIRECT) http.createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, openssl: process.versions.openssl, pqGroup: PQ_GROUP, cached: cache.size, inProgress: inLucru }); + // seria de adoptie PQ (adoptie-pq.mjs): editia cea mai noua sau una datata; numai agregatul public, niciodata dosarul privat + const editie = /^\/adoption(?:\/(\d{4}-\d{2}-\d{2}))?$/.exec(url.pathname); + if (req.method === 'GET' && editie) { + try { return json(res, 200, JSON.parse(readFileSync(`${ADOPTIE_DIR}/${editie[1] || 'latest'}.json`, 'utf8'))); } + catch { return json(res, 404, { error: 'no_edition', hint: editie[1] ? 'no edition was published on that date' : 'the first weekly edition is not published yet' }); } + } + // B-16 (2026-09-29): limita de ritm se tine pe clientul numit de gateway (x-aere-client, calculat din Cloudflare si nginx), NU pe + // X-Forwarded-For: primul lui element il alege clientul, deci limita se ocolea schimbandu-l. Serviciul asculta numai pe + // 127.0.0.1, deci antetul il poate pune numai cine ruleaza pe gazda (gateway-ul). + const ip = String(req.headers['x-aere-client'] || req.socket.remoteAddress || '').trim(); + let domain = null, fresh = false; + if (req.method === 'POST' && url.pathname === '/scan') { + let b; try { b = JSON.parse((await corp(req)) || 'null'); } catch { return json(res, 400, { error: 'bad_json' }); } + domain = String(b?.domain || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, ''); + fresh = b?.fresh === true; + } else if (req.method === 'GET' && url.pathname.startsWith('/scan/')) { + domain = decodeURIComponent(url.pathname.slice(6)).trim().toLowerCase(); + } else return json(res, 404, { error: 'not_found' }); + if (!domeniuValid(domain)) return json(res, 400, { error: 'bad_domain', hint: 'a public hostname, e.g. example.com' }); + const c = cache.get(domain); + if (c && !fresh && Date.now() - c.at < TTL_MS) return json(res, 200, { ...c.report, cached: true }); + if (req.method === 'GET') return json(res, 404, { error: 'not_scanned_yet', hint: 'POST /scan {"domain": "..."}' }); + if (!ritmOk(ip)) return json(res, 429, { error: 'rate_limited', hint: 'at most 12 scans per minute per client' }); + let report; + try { report = await cuLimita(() => scaneaza(domain)); } catch (e) { if (e && e.busy) return json(res, 503, { error: 'busy', hint: 'too many scans are waiting; retry in a minute' }); throw e; } + if (!report.error) cache.set(domain, { at: Date.now(), report }); + if (cache.size > 5000) { const k = cache.keys().next().value; cache.delete(k); } + return json(res, report.error ? 422 : 200, report); + } catch (e) { return json(res, 500, { error: 'internal', detail: String(e.message || e).slice(0, 80) }); } +}).listen(PORT, '127.0.0.1', () => console.log(`aere-quantum-readiness on 127.0.0.1:${PORT}, OpenSSL ${process.versions.openssl}, group ${PQ_GROUP}`));