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:
parent
3465550e91
commit
a7165164eb
@ -12,6 +12,8 @@ command of the verification layer, which needs `ethers`.
|
||||
| [`crypto-inventory/`](crypto-inventory/) | a cryptographic inventory of source code (JavaScript/TypeScript, Python, Java, Go, PEM blocks, dependency manifests): every use classified by its exposure to a quantum computer, with a migration target, written as a CycloneDX 1.6 CBOM; nothing from the scanned tree is executed, and its cost stays linear on input built to be slow |
|
||||
| [`verify-layer/`](verify-layer/) | an audit-log sidecar for any deployment: entries are AIP-23 envelopes in a hash chain, the runtime adapter records every running Docker or Kubernetes container without any secret value, and the head of the chain can be notarized on Aere Network for post-quantum finality; it says plainly what that proves (the history before a published head) and what it does not (that the host told the truth) |
|
||||
| [`proof-kinds/`](proof-kinds/) | the AIP-23 envelope builder the verification layer uses: fourteen proof kinds, one envelope format, digests instead of raw content |
|
||||
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
|
||||
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
|
||||
|
||||
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
||||
|
||||
@ -29,6 +31,8 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j
|
||||
| crypto-inventory | 37/37 (`node test/proba.mjs`); cost on hostile input 8/8 linear (`node test/proba-timp.mjs`) | 18/18 (`node test/control-negativ.mjs`); cost 3/3 in this repository (`node test/control-negativ-timp.mjs`; its fourth case compares with version 0.1.0 from the development history and is skipped here) |
|
||||
| verify-layer | 34/34 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<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 |
|
||||
| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) |
|
||||
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8 | remediation 7/7, compliance report 3/3 in this repository |
|
||||
|
||||
Code comments, most function and variable names (also many exported between the files of a component), test names and control
|
||||
messages are in Romanian, and so are the two command words of the KMS HSM tool (explained in its README). Error codes, error
|
||||
@ -37,4 +41,4 @@ in English.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT, see [LICENSE](LICENSE). Files: 77 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3).
|
||||
MIT, see [LICENSE](LICENSE). Files: 98 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, readiness 4).
|
||||
|
||||
97
control-plane/README.md
Normal file
97
control-plane/README.md
Normal 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.
|
||||
12
control-plane/consola-web/README.md
Normal file
12
control-plane/consola-web/README.md
Normal 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.
|
||||
341
control-plane/consola-web/index.html
Normal file
341
control-plane/consola-web/index.html
Normal 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
107
control-plane/consola.mjs
Normal 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; });
|
||||
}
|
||||
63
control-plane/control-negativ-raport-conformitate.mjs
Normal file
63
control-plane/control-negativ-raport-conformitate.mjs
Normal 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;
|
||||
54
control-plane/control-negativ-remediere.mjs
Normal file
54
control-plane/control-negativ-remediere.mjs
Normal 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;
|
||||
86
control-plane/control-plane.mjs
Normal file
86
control-plane/control-plane.mjs
Normal 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); });
|
||||
}
|
||||
251
control-plane/executa-migrare.mjs
Normal file
251
control-plane/executa-migrare.mjs
Normal 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; }
|
||||
}
|
||||
194
control-plane/plan-migrare.mjs
Normal file
194
control-plane/plan-migrare.mjs
Normal 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 };
|
||||
79
control-plane/proba-consola.mjs
Normal file
79
control-plane/proba-consola.mjs
Normal 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;
|
||||
59
control-plane/proba-control-plane.mjs
Normal file
59
control-plane/proba-control-plane.mjs
Normal 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);
|
||||
211
control-plane/proba-executa-migrare.mjs
Normal file
211
control-plane/proba-executa-migrare.mjs
Normal 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;
|
||||
131
control-plane/proba-plan-migrare.mjs
Normal file
131
control-plane/proba-plan-migrare.mjs
Normal 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;
|
||||
122
control-plane/proba-raport-conformitate.mjs
Normal file
122
control-plane/proba-raport-conformitate.mjs
Normal 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;
|
||||
133
control-plane/proba-remediere.mjs
Normal file
133
control-plane/proba-remediere.mjs
Normal 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;
|
||||
176
control-plane/raport-conformitate.mjs
Normal file
176
control-plane/raport-conformitate.mjs
Normal 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
255
control-plane/remediere.mjs
Normal 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
49
readiness/README.md
Normal 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.
|
||||
62
readiness/adrese-private.mjs
Normal file
62
readiness/adrese-private.mjs
Normal 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);
|
||||
};
|
||||
64
readiness/proba-adrese-private.mjs
Normal file
64
readiness/proba-adrese-private.mjs
Normal 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;
|
||||
192
readiness/readiness-service.mjs
Normal file
192
readiness/readiness-service.mjs
Normal 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}`));
|
||||
Loading…
Reference in New Issue
Block a user