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.