aere-quantum/control-plane/README.md

19 KiB

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:

Since 2026-10-18 (first run on a code base that is not ours, a Rust messenger): a key agreement that the application calls itself (X25519 in a ratchet, a PQXDH handshake, a Tor ntor handshake, createECDH in Node) is not routed to the PQ Gateway, because a TLS edge cannot see it; its action is manual, in code, with the hybrid target written out (X25519 + ML-KEM-768 in the same handshake and key schedule). A signature or public-key encryption that the application performs itself keeps its PKI/KMS action (the product issues the hybrid key, for real), and how says that the call site must then be changed to use it. Only a finding with no call context (no api, no language) keeps the old routing: its context is not known, so it is not guessed. The inventory's TLS configuration findings still go to the gateway.

{ ref, asset, problem, current, target, method: "auto-aere" | "manual" | "blocked", product: "gateway" | "kms" | "pki" | null,
  how, cn?, primitive?, urgency: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW", blocker, scope: "production" | "test", location, source }

Two rules added on 2026-10-18 after the run on the libsignal tree. A finding in test code (tests/, benches/, fuzz/, examples/, *Test.java, *.test.ts, ...; the path rule lives in scopulCaii) stays in the plan but with scope: "test", urgency LOW and a how that says so, and summary.byScope counts it apart: on libsignal 76 of 197 actions (75 of them HIGH) were in such files and stood in front of the production call sites. A self-signed certificate that is a pinned trust anchor (valid for more than five years, or whose CN is not a host name: the AMD, Google and AWS attestation roots in libsignal) is blocked with the reason written, not "auto through the PQ PKI": the PKI cannot replace a root the verifier pins, only its owner can publish a post-quantum one. A short self-signed service certificate for a host name is still reissued by the PKI for the same CN.

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): since 2026-10-18 all five profiles end to end, on real servers started from exactly the recipe's fragments (nginx 1.31.7 with OpenSSL 3.5.8/3.5.9, Apache 2.4.69 with OpenSSL 3.5.9 and HAProxy 3.4.7 with OpenSSL 3.5.9, each in a container; Go 1.26.5; Node earlier): a client offering only the hybrid group is accepted, a TLS 1.2 client is refused, HSTS is sent (dovezi/retete-2026-10-18/); not measured: a production server with its full configuration, real browsers as clients.

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.

6. Organization judgement: organizatie.mjs

node organizatie.mjs --pack pack.json [--context context.json] [--require shield,identity,...] [--at <RFC 3339>] [--online] [--json]

Exit code 0 = VALID, 1 = INVALID, 2 = NOT_MEASURED (or the command could not run), 3 = PARTIAL.

An organization brings an evidence pack; every artifact in it is judged again by the published verifier of its kind, and the result is one verdict per domain and a summary that is never stronger than the weakest domain that was asked for. Nothing the pack says about itself is believed.

The pack (aere-organization-evidence-pack):

{ "kind": "aere-organization-evidence-pack", "organization": "...", "createdAt": "...",
  "artifacts": [ { "id": "...", "artifact": "<kind>", "input": { ... } }, ... ] }

id is letters, digits and . _ : / -, at most 128 characters, unique in the pack (a repeated id is judged once, its copies are NOT_MEASURED). artifact is one of 25 kinds in eight domains:

domain kinds
shield shield-console (a sidecar bundle, the plan and the execution chain, judged by consola.mjs; an execution given with a plan must be the executor's run of that plan)
identity identity-presentation, sdjwt-presentation, sdjwt-vc-presentation
compliance compliance-presentation, travel-rule-binding, travel-rule-message, travel-rule-receipt, pq-compliance-report (a report of section 4 and its envelope)
ai ai-trust-bundle, agent-ledger
payments payment-risk, payment-receipt, market-timeline
devices device-bundle, device-operation-log, device-reputation
audit audit-log, audit-inclusion, audit-consistency
proofs envelope (AIP-23), dataset-record, dataset-answer, dataset-consistency, dataset-sample

Cloud is not a domain of its own: what a Cloud product returns is judged in the domain of the artifact. The 23 kinds of the conformance suite are judged through the suite's adapter (../conformance/implementare-aere.mjs beside a public copy of the control plane), called as a program, so a verifier that does not start is seen as such; the two kinds of the control plane itself are judged here (shield-console by the console's own evaluation, plus the binding of the execution to the plan given with it; pq-compliance-report by verifyReport, by making the report again from its own findings with the same generator, which must give exactly the report and the envelope's statement, and by the AIP-23 verifier for its envelope). The compliance envelope is not signed, so a report bound to it shows only that the report is self-consistent; whether its findings are the real plan's is not judged. A kind no verifier here judges is listed as unjudged.

The context (aere-organization-context) belongs to the party that relies on the pack, not to the organization:

{ "kind": "aere-organization-context", "now": "<RFC 3339>", "require": ["identity", ...],
  "byKind": { "<kind>": { ... } }, "byArtifact": { "<id>": { ... } } }

Every input of every verifier is classified (the test derives the keys from the adapter's source, from the source of the module the adapter hands a whole input to, and from the 791 vectors of the suite, and fails on a key without a class): the artifact comes only from the pack; a stapled input (a status list, a revocation, a firmware policy, signed by a third party) is taken from the context and from the pack, joined, the context's lists first (a verifier reads only the first entries of a long list, so lists brought by the pack cannot push the relying party's own revocation out of what is read); a verifier input (the trusted issuers or manufacturers, the audience, the nonce, the expected values, the relying party's own readings: a market's arbiter and arbitration timeout, the facts a reputation is computed from) comes from the context, and if only the pack brings it, it is used but the artifact cannot be VALID (PARTIAL, because the organization chose its own trust); a verifier-only input (the chain to read, a beneficiary's private KEM keys, the time limit) is never taken from the pack; the clock comes from the context for that artifact or kind, then --at, then the context's global now, then this machine, never from the pack. For a market timeline and a reputation, whose module reads the whole input, a key the module does not read by name stays with the artifact (so a member too many is still refused).

The verdicts. Each artifact gets the verifier's verdict (VALID, INVALID, PARTIAL), or NOT_MEASURED when the verifier did not run or gave no verdict of the suite's form: a verdict whose own levels do not give it (by the suite's rule in verdict.mjs), or a verdict for another kind than the one asked in its place (the order of the adapter's answers cannot then be trusted, so none of them is). When a batch does not complete (too few answers, or the time limit), each artifact is judged alone, so one artifact cannot take the verdicts of the others with it. A domain is as weak as its weakest artifact; a domain with no artifact is NOT_PRESENTED, and a required domain that is not presented makes the summary NOT_MEASURED, never VALID. The required domains are --require, then context.require, then all eight. Unjudged artifacts cap a VALID summary at PARTIAL (the pack claims more than was measured). A verdict written in the pack (verdict, summary.verdict, an artifact's verdict or expected.verdict) is ignored, and named as suspect when it differs from the measurement.

The network. Anchoring and finality on a chain are read only with --online, and only for the kinds whose verifier reads the chain (an AIP-23 envelope, a payment receipt, a market timeline, an AI trust bundle, the report's envelope). Without it, each of those that claims a notarization is listed in network.needed and its levels say NOT_MEASURED. A device bundle or an operation log judges its notarization on the relying party's own readings (anchors, readings in the context), never on the chain read here, with --online or not: when they are missing it is listed in network.readings. network.unmeasured lists what --online could not read, and network.finality names the chain each finality was read on, a testnet said as such (the summary says it too). The console's Shield evaluation does not read a chain even with --online: a notarization claimed inside a sidecar bundle has to be presented as its own envelope artifact.

What it does not judge. The truth of what an artifact asserts beyond what its verifier checks (a market timeline, a payment receipt without the relying party's transaction readings, the findings of a compliance report), the honesty of the host that produced a sidecar bundle (section 5), and any kind outside the table. consola-web/index.html shows the output (--json) domain by domain, each artifact with its verifier and the evidence it rests on; a pack pasted there directly is shown as UNCHECKED. The view judges nothing, but it never shows a verdict stronger than the output's own rows give (a domain no stronger than its weakest artifact, the summary no stronger than its weakest required domain): an edited output that contradicts itself is shown at the weaker reading and named. Everything that comes from the pack is shown as text.

Tests

bash probele-b1.sh                  # all seven below, each VERDE / ROSU / STRICAT (in the development repository only)
node proba-plan-migrare.mjs         # 44: real certificates and keys (openssl) through the real inventory; the scanner's ids derived from its source
node proba-control-plane.mjs        # 10: 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  # 30: 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
node proba-consola-web.mjs          # 26: the viewer in a real Chromium, phone (390 px) and desktop (1440 px); needs playwright-core and a Chromium
node control-negativ-consola-web.mjs   # 8 defects planted in a copy of the page, each turns its named check red
node proba-organizatie.mjs          # 74 (75 with --online): artifacts made by the real producers (the suite's vectors, checked against INDEX.json; a Shield state,
                                    #     a plan and a report made now), a good pack and one broken artifact per kind, and the attacks of the
                                    #     adversarial review of 2026-10-21; --online adds a row on testnet 28001
node control-negativ-organizatie.mjs   # 45 of 45 guards removed in a copy, each turns its named row red
node proba-organizatie-web.mjs      # 48: the organization view in a real Chromium, 390 px and 1440 px, edited outputs and text from the pack included
node control-negativ-organizatie-web.mjs   # 20 defects planted in a copy of the page, each turns its named check red

The report test runs the envelope through the AIP-23 reference verifier (AERE_VERIFY_PROOF=<verify-proof.mjs from aere.network/tools>). The counts above were measured on 2026-10-21 in the development repository and from a fresh clone of the public one (until that day the planner and command-line lines said 30 and 9, counts from before rows were added to those two probes). No third party has reviewed any of this.