Add control-plane (plan, execution with consent per action, remediation recipes and proof, compliance report, console) and readiness (the post-quantum readiness scanner of a hostname)

This commit is contained in:
Aere Network 2026-09-29 21:21:59 +03:00
parent 3465550e91
commit a7165164eb
22 changed files with 2743 additions and 1 deletions

View File

@ -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 | | [`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) | | [`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 | | [`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. 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) | | 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=<verify-proof.mjs from aere-node> 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) | | verify-layer | 34/34 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs from aere-node> 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 | | 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 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 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 ## 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).

97
control-plane/README.md Normal file
View File

@ -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 <dir> [--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 <dir> [--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=<verify-proof.mjs from aere-node>`).
No third party has reviewed any of this.

View File

@ -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.

View File

@ -0,0 +1,341 @@
<title>AERE Verification Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
color-scheme: dark;
--bg:#0a0e13; --panel:#111721; --panel2:#0e141d; --line:#1e2732; --line2:#2a3644;
--ink:#e7edf4; --mut:#8a99ab; --dim:#5f6e80;
--accent:#39d3c8; --accent-dim:#1b6d69;
--ok:#4bcf8e; --warn:#e3ab4d; --crit:#f2685f;
--shadow:0 1px 0 rgba(255,255,255,.02), 0 12px 30px -18px rgba(0,0,0,.8);
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
--sans:"IBM Plex Sans",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
:root[data-theme="light"]{
color-scheme: light;
--bg:#f3f5f8; --panel:#ffffff; --panel2:#f7f9fb; --line:#e3e8ef; --line2:#d3dbe5;
--ink:#16202c; --mut:#5a6b7d; --dim:#8a99ab;
--accent:#0e928a; --accent-dim:#a7e6e1;
--ok:#12915c; --warn:#a9720a; --crit:#c93a30;
--shadow:0 1px 2px rgba(16,32,48,.06), 0 12px 28px -20px rgba(16,32,48,.35);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
.wrap{max-width:1000px;margin:0 auto;padding-inline:16px;padding-block:0 56px}
a{color:var(--accent)}
.mono{font-family:var(--mono);font-variant-ligatures:none}
.tnum{font-variant-numeric:tabular-nums}
/* header */
header{position:sticky;top:env(safe-area-inset-top,0px);z-index:5;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
.hbar{max-width:1000px;margin:0 auto;padding:14px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.mark{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:.02em}
.glyph{width:26px;height:26px;flex:none}
.mark .sub{font-weight:500;color:var(--mut);font-size:12px;letter-spacing:.06em;text-transform:uppercase}
.host{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--mut);font-size:12.5px}
.host .mono{color:var(--ink)}
.verdict{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;font-weight:700;font-size:13px;letter-spacing:.03em;border:1px solid}
.verdict.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
.verdict.bad{color:var(--crit);border-color:color-mix(in srgb,var(--crit) 45%,transparent);background:color-mix(in srgb,var(--crit) 12%,transparent)}
.dot{width:8px;height:8px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px color-mix(in srgb,currentColor 22%,transparent)}
/* intro line */
.lead{color:var(--mut);font-size:13px;margin:18px 0 4px}
.lead b{color:var(--ink);font-weight:600}
/* tiles */
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0 8px}
@media(max-width:760px){.tiles{grid-template-columns:repeat(2,1fr)}}
@media(max-width:420px){.tiles{grid-template-columns:1fr}}
.tile{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 15px;box-shadow:var(--shadow);display:flex;flex-direction:column;gap:6px;min-height:98px}
.tile .k{font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);display:flex;align-items:center;gap:7px}
.tile .v{font-size:24px;font-weight:700;font-family:var(--mono);line-height:1.1}
.tile .n{font-size:12px;color:var(--mut)}
.stripe{height:3px;border-radius:2px;margin-top:auto}
.s-ok{background:var(--ok)} .s-warn{background:var(--warn)} .s-crit{background:var(--crit)} .s-acc{background:var(--accent)}
.pin{width:7px;height:7px;border-radius:50%} .pin.ok{background:var(--ok)} .pin.warn{background:var(--warn)} .pin.crit{background:var(--crit)} .pin.acc{background:var(--accent)}
/* section */
section{margin-top:28px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.1em;color:var(--mut);font-weight:600;margin:0 0 12px;display:flex;align-items:center;gap:9px}
h2 .rule{height:1px;background:var(--line);flex:1}
/* chain */
.chain{display:flex;flex-direction:column;gap:0}
.node{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;box-shadow:var(--shadow)}
.node + .link{height:26px;margin-left:26px;border-left:2px dashed var(--line2);position:relative}
.node + .link::after{content:"prev";position:absolute;left:12px;top:50%;transform:translateY(-50%);font-family:var(--mono);font-size:10px;color:var(--dim);letter-spacing:.05em;background:var(--bg);padding:0 6px}
.node.head{border-color:color-mix(in srgb,var(--accent) 55%,var(--line));box-shadow:var(--shadow),0 0 0 1px color-mix(in srgb,var(--accent) 30%,transparent)}
.nrow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.seq{font-family:var(--mono);font-weight:600;color:var(--accent);font-size:13px;min-width:30px}
.kind{display:inline-flex;align-items:center;gap:7px;font-weight:600;font-size:13.5px}
.kind .tag{font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border:1px solid var(--line2);border-radius:5px;padding:1px 6px}
.nname{color:var(--mut);font-size:12.5px}
.nat{margin-left:auto;color:var(--dim);font-size:11.5px;font-family:var(--mono)}
.headflag{font-size:10.5px;color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:999px;padding:1px 8px;letter-spacing:.05em;text-transform:uppercase}
.hashline{margin-top:9px;display:grid;grid-template-columns:auto 1fr auto;gap:8px 10px;align-items:center}
.hlab{font-family:var(--mono);font-size:10.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.05em}
.hval{font-family:var(--mono);font-size:12px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hval.dim{color:var(--mut)}
.copy{border:1px solid var(--line2);background:transparent;color:var(--mut);border-radius:6px;font-family:var(--mono);font-size:10.5px;padding:2px 8px;cursor:pointer;transition:.15s}
.copy:hover{color:var(--ink);border-color:var(--accent)}
.copy.done{color:var(--ok);border-color:var(--ok)}
/* ladder */
.ladder{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
@media(max-width:640px){.ladder{grid-template-columns:1fr 1fr}}
.step{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:6px;position:relative;opacity:.62}
.step.cur{opacity:1;background:var(--panel);border-color:color-mix(in srgb,var(--accent) 50%,var(--line));box-shadow:var(--shadow)}
.step .lv{font-family:var(--mono);font-size:11px;color:var(--dim)}
.step .nm{font-weight:600;font-size:13px}
.step .ds{font-size:11.5px;color:var(--mut)}
.step.cur .nm{color:var(--accent)}
/* migration bars */
.mig{display:grid;grid-template-columns:1.3fr 1fr;gap:14px}
@media(max-width:640px){.mig{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:15px;box-shadow:var(--shadow)}
.bar{height:10px;border-radius:6px;background:var(--panel2);overflow:hidden;display:flex;margin:10px 0}
.bar i{display:block;height:100%}
.legend{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--mut)}
.legend span{display:inline-flex;align-items:center;gap:7px}
.mrow{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;border-top:1px solid var(--line)}
.mrow:first-of-type{border-top:0}
.mrow .b{font-weight:600;color:var(--ink);font-family:var(--mono)}
/* paste */
.paste{margin-top:14px}
textarea{width:100%;min-height:120px;background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:12px;font-family:var(--mono);font-size:12px;resize:vertical}
textarea:focus{outline:2px solid color-mix(in srgb,var(--accent) 50%,transparent);outline-offset:1px;border-color:var(--accent)}
.row{display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap}
button.act{border:1px solid var(--accent-dim);background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent);font-family:var(--sans);font-weight:600;font-size:13px;padding:8px 16px;border-radius:8px;cursor:pointer}
button.act:hover{background:color-mix(in srgb,var(--accent) 26%,transparent)}
button.ghost{border:1px solid var(--line2);background:transparent;color:var(--mut)}
.err{color:var(--crit);font-size:12.5px;font-family:var(--mono)}
.foot{margin-top:34px;padding-top:16px;border-top:1px solid var(--line);color:var(--dim);font-size:11.5px;line-height:1.7}
.foot code{font-family:var(--mono);color:var(--mut)}
.warnbox{margin-top:12px;border:1px solid color-mix(in srgb,var(--crit) 45%,transparent);background:color-mix(in srgb,var(--crit) 10%,transparent);color:var(--crit);border-radius:10px;padding:11px 13px;font-size:12.5px}
</style>
<header>
<div class="hbar">
<div class="mark">
<svg class="glyph" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M16 2 3 8v8c0 8 5.4 12.6 13 14 7.6-1.4 13-6 13-14V8L16 2Z" fill="none" stroke="var(--accent)" stroke-width="1.6"/>
<path d="M10.5 16.2 14.4 20l7.3-8.2" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div>AERE Verification Console<div class="sub">quantum security control plane</div></div>
</div>
<div class="host">
<span>host</span><span class="mono" id="host">—</span>
<span id="verdict" class="verdict ok"><span class="dot"></span><span id="vtext">OK</span></span>
</div>
</div>
</header>
<div class="wrap">
<p class="lead">The state of a deployment, <b>checked again independently</b> 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.</p>
<div class="tiles" id="tiles"></div>
<div id="alert"></div>
<section>
<h2>Audit chain <span class="rule"></span> <span id="chainmeta" class="mono" style="font-size:11px;color:var(--dim);text-transform:none;letter-spacing:0"></span></h2>
<div class="chain" id="chain"></div>
</section>
<section>
<h2>Post-quantum finality <span class="rule"></span></h2>
<div class="ladder" id="ladder"></div>
</section>
<section>
<h2>Post-quantum migration <span class="rule"></span></h2>
<div class="mig">
<div class="card">
<div style="font-weight:600;margin-bottom:2px">Migration plan</div>
<div class="n" style="color:var(--mut);font-size:12px">actions derived from the cryptographic inventory of the deployment</div>
<div class="bar" id="migbar"></div>
<div class="legend">
<span><i class="pin crit"></i> critical</span>
<span><i class="pin acc"></i> automatic</span>
<span><i class="pin warn"></i> manual</span>
</div>
</div>
<div class="card" id="migrows"></div>
</div>
</section>
<section class="paste">
<h2>Render your own output <span class="rule"></span></h2>
<p class="lead" style="margin-top:0">Paste the output of <code class="mono" style="color:var(--ink)">node consola.mjs --bundle b.json --json</code> or a bundle from <code class="mono" style="color:var(--ink)">node sidecar.mjs bundle</code>.</p>
<textarea id="in" spellcheck="false" placeholder='{ "kind":"aere-control-plane-console", ... }'></textarea>
<div class="row">
<button class="act" id="render">Render</button>
<button class="act ghost" id="reset">Example</button>
<span class="err" id="perr" hidden></span>
</div>
</section>
<div class="foot">
A rendering of the output of the AUTHORITATIVE tools (<code>consola.mjs</code> 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 <code>sha256(seq | prev | envelope)</code>; 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).
</div>
</div>
<script>
const GEN = "0x"+"00".repeat(32);
const short = h => !h ? "—" : (h.length>22 ? h.slice(0,12)+"…"+h.slice(-8) : h);
const KINDNAME = {
"aere-proof-of-runtime":["Runtime","the running binary is the attested one"],
"aere-proof-of-data":["Deployment","the deployed artifact, as a digest"],
"aere-audit-head":["Log head","can be notarized"],
};
// an example generated by the verification layer and the control plane (console output format 2)
const SAMPLE = {
v:2, kind:"aere-control-plane-console", host:"aws-eu-prod-1",
auditChain:{ok:true,count:3,head:"0x5f63faec605908668fee3703303b006e2ebe66b3318b9fc8d1f01e86edfb41ee",brokenAtSeq:null,reason:null},
envelopes:{total:3,intact:3,bad:[]},
selfReportSuspect:null,
migration:{total:3,critical:2,auto:2,manual:1,blocked:0},
execution:null,
verdict:"OK",
entries:[
{seq:0,kind:"aere-proof-of-runtime",at:"2026-09-26T08:00:00Z",name:"aws-eu-prod-1",statementHash:"0x1c0183e10520f70884dea2947c763413f8514606026e9fe294bc4cfdb380be67",hash:"0x6168463c93c47acbd8b40f02ed5089e87a77c07a0a2e8a431026bd7f765f8969",prev:GEN},
{seq:1,kind:"aere-proof-of-data",at:"2026-09-26T08:05:00Z",name:"checkout-api@4.2.1",statementHash:"0x5fa286c04da5fe37f4a9c9963999b4162926d81064b95af28a91a665f86b2c68",hash:"0xf2ff1835e76511a902ec02f8fa523a455d114743e1e916abe11cfc8b822579e8",prev:"0x6168463c93c47acbd8b40f02ed5089e87a77c07a0a2e8a431026bd7f765f8969"},
{seq:2,kind:"aere-proof-of-runtime",at:"2026-09-26T08:10:00Z",name:"aws-eu-prod-1",statementHash:"0xa9358d663d8ec8b3a59c7db6a2eab2ed842d2adac61f6d84d43657a7b831c4d5",hash:"0x5f63faec605908668fee3703303b006e2ebe66b3318b9fc8d1f01e86edfb41ee",prev:"0xf2ff1835e76511a902ec02f8fa523a455d114743e1e916abe11cfc8b822579e8"}
]
};
function normalize(o){
// accepts a console state OR a sidecar bundle (it has entries + chainOk); a bundle is shown as the bundle declares it, since the
// browser does not recompute anything: run consola.mjs on it for a checked verdict
if(o.kind==="aere-verify-layer-bundle" || (o.entries && !o.auditChain)){
return {
kind:"aere-control-plane-console", host:o.host||"?",
auditChain:{ok:!!o.chainOk,count:o.count??(o.entries||[]).length,head:o.head||null,brokenAtSeq:null,reason:null},
envelopes:{total:(o.entries||[]).length,intact:(o.entries||[]).length,bad:[]},
selfReportSuspect:null, migration:null, execution:null, verdict:"UNCHECKED",
note:"A bundle pasted here is shown as it declares itself (chainOk="+(!!o.chainOk)+"): the browser checks nothing. Run node consola.mjs --bundle on it for a checked verdict.",
entries:(o.entries||[]).map(e=>({seq:e.seq,kind:e.proof?.statement?.kind||"?",at:e.proof?.statement?.createdAt||"",name:e.proof?.statement?.name||e.proof?.statement?.host||"",statementHash:e.proof?.statementHash||"",hash:e.hash,prev:e.prev}))
};
}
return o;
}
function el(t,c,x){const n=document.createElement(t);if(c)n.className=c;if(x!=null)n.textContent=x;return n;}
function copyBtn(text){
const b=el("button","copy","copy");
b.addEventListener("click",async()=>{
try{await navigator.clipboard.writeText(text);}catch(e){/* the clipboard is not available here */}
b.textContent="copied";b.classList.add("done");setTimeout(()=>{b.textContent="copy";b.classList.remove("done");},1200);
});
return b;
}
function render(d){
d=normalize(d);
const ok = d.verdict==="OK";
document.getElementById("host").textContent=d.host||"?";
const v=document.getElementById("verdict"); v.className="verdict "+(ok?"ok":"bad");
document.getElementById("vtext").textContent=d.verdict||"?";
// tiles
const ac=d.auditChain||{}, pl=d.envelopes||{}, mg=d.migration, ex=d.execution;
const bad=(pl.bad&&pl.bad.length);
const tiles=[
{k:"Audit chain",pin:ac.ok?"ok":"crit",v:ac.ok?"INTACT":"BROKEN",n:(ac.count??0)+" entries"+(ac.ok?"":" · broken at seq "+ac.brokenAtSeq),s:ac.ok?"s-ok":"s-crit"},
{k:"AIP-23 envelopes",pin:bad?"crit":"ok",v:(pl.intact??0)+"/"+(pl.total??0),n:bad?"intact · bad: "+pl.bad.map(x=>x.seq).join(","):"intact",s:bad?"s-crit":"s-ok"},
{k:"PQ migration",pin:mg?(mg.critical?"crit":"acc"):"acc",v:mg&&mg.total!=null?String(mg.total):"—",n:mg?(mg.total==null?(mg.error||"plan not readable"):(mg.critical+" critical · "+mg.auto+" automatic")):"no plan attached",s:mg&&mg.critical?"s-crit":"s-acc"},
ex?{k:"Execution",pin:ex.chainOk?(ex.failed?"crit":"ok"):"crit",v:ex.chainOk?"INTACT":"BROKEN",n:ex.actions+" actions · "+ex.ok+" OK · "+ex.failed+" failed",s:ex.chainOk&&!ex.failed?"s-ok":"s-crit"}
:{k:"Finality",pin:"warn",v:"ready",n:"the head can be notarized (not notarized)",s:"s-warn"},
];
const T=document.getElementById("tiles");T.innerHTML="";
for(const t of tiles){
const c=el("div","tile");
const k=el("div","k");k.append(el("i","pin "+t.pin),document.createTextNode(t.k));
c.append(k,el("div","v",t.v),el("div","n",t.n),el("div","stripe "+t.s));
T.append(c);
}
// alert
const A=document.getElementById("alert");A.innerHTML="";
if(d.selfReportSuspect){const w=el("div","warnbox");w.textContent="Suspect self-report: "+d.selfReportSuspect;A.append(w);}
if(d.note){const w=el("div","warnbox");w.textContent=d.note;A.append(w);}
// chain
const C=document.getElementById("chain");C.innerHTML="";
const cm=document.getElementById("chainmeta");
cm.textContent = ac.head ? ("head "+short(ac.head)) : "";
const entries=d.entries||[];
if(!entries.length){
C.append(el("div","card","No per-entry detail (only the console summary was pasted). Paste a bundle from the sidecar for the whole chain."));
}
entries.forEach((e,i)=>{
if(i>0){C.append(el("div","link"));}
const isHead = e.hash===ac.head;
const n=el("div","node"+(isHead?" head":""));
const row=el("div","nrow");
row.append(el("span","seq","#"+e.seq));
const kn=KINDNAME[e.kind]||[e.kind,""];
const kind=el("span","kind");kind.append(document.createTextNode(kn[0]));const tag=el("span","tag",String(e.kind).replace("aere-proof-of-","").replace("aere-",""));kind.append(tag);
row.append(kind);
if(e.name)row.append(el("span","nname",e.name));
if(isHead)row.append(el("span","headflag","head"));
row.append(el("span","nat",(e.at||"").replace("T"," ").replace("Z","")));
n.append(row);
const hl=el("div","hashline");
hl.append(el("span","hlab","hash"));const hv=el("span","hval",e.hash||"—");hl.append(hv,copyBtn(e.hash||""));
hl.append(el("span","hlab","prev"));const pv=el("span","hval dim",e.prev===GEN?"genesis 0x00…00":short(e.prev));hl.append(pv,el("span"));
n.append(hl);
C.append(n);
});
// ladder: the finality steps of the AIP-23 verifier; the step marked is the one of the example (a head not yet notarized)
const steps=[
["1","not notarized","the statementHash of the head is not on AereNotary yet"],
["2","pending","a notarization transaction was seen, not yet under an anchor"],
["3","anchor without certificate","in a block, under an anchor without a complete post-quantum certificate"],
["4","post-quantum","under an anchor with a Falcon / SLH-DSA certificate"],
];
const cur = 1;
const L=document.getElementById("ladder");L.innerHTML="";
steps.forEach((s,i)=>{
const st=el("div","step"+(i+1===cur?" cur":""));
st.append(el("div","lv","step "+s[0]),el("div","nm",s[1]),el("div","ds",s[2]));
L.append(st);
});
// migration
const mb=document.getElementById("migbar");mb.innerHTML="";mb.style.display="";
const mr=document.getElementById("migrows");mr.innerHTML="";
if(mg && mg.total){
// the bar: critical / automatic / manual scaled to their sum (a visual approximation over categories of urgency and method)
const parts=[["var(--crit)",mg.critical,"critical"],["var(--accent)",mg.auto,"automatic"],["var(--warn)",mg.manual,"manual"]];
const sum=parts.reduce((a,p)=>a+p[1],0)||1;
for(const p of parts){const i=el("i");i.style.background=p[0];i.style.width=(p[1]/sum*100)+"%";mb.append(i);}
for(const [lab,val] of [["Total actions",mg.total],["Critical",mg.critical],["Automatic",mg.auto],["Manual",mg.manual],["Blocked",mg.blocked??0]]){
const r=el("div","mrow");r.append(el("span",null,lab),el("span","b tnum",String(val)));mr.append(r);
}
} else {
mb.style.display="none";
mr.append(el("div","n","No migration plan attached. Run node consola.mjs --bundle b.json --plan plan.json."));
}
}
document.getElementById("render").addEventListener("click",()=>{
const e=document.getElementById("perr");e.hidden=true;
try{ const o=JSON.parse(document.getElementById("in").value.trim()); render(o); }
catch(err){ e.hidden=false; e.textContent="invalid JSON: "+err.message; }
});
document.getElementById("reset").addEventListener("click",()=>{
document.getElementById("in").value=JSON.stringify(SAMPLE,null,1);
document.getElementById("perr").hidden=true; render(SAMPLE);
});
render(SAMPLE);
</script>

107
control-plane/consola.mjs Normal file
View File

@ -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; });
}

View File

@ -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;

View File

@ -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;

View File

@ -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 <fisier.json>.
// Iesirea e in engleza (forma planului 2, vezi plan-migrare.mjs).
//
// node control-plane.mjs --code <dir> [--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 <dir> [--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); });
}

View File

@ -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 <dir> [--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; }
}

View File

@ -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 };

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 };

255
control-plane/remediere.mjs Normal file
View File

@ -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 <domain>'] },
'chain-untrusted': { operational: 'serve the complete chain (the leaf certificate and the intermediates), not only the leaf', commands: ['openssl s_client -connect <domain>:443 -servername <domain> -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 <domain>`, `openssl s_client -connect <domain>: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 <VirtualHost *:443> 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 <your server file>', 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 <your server binary>' } : 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('<domain>', 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; }
}

49
readiness/README.md Normal file
View File

@ -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/<domain>`
(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.

View File

@ -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);
};

View File

@ -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;

View File

@ -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}`));