Aere Quantum: a post-quantum TLS 1.3 gateway (X25519MLKEM768, optional ML-DSA client authentication), a hybrid KMS (X25519 + ML-KEM-768, Ed25519 + ML-DSA-65; root key from the environment or sealed by an HSM through PKCS#11) and an ML-DSA private CA (X.509 v3, RFC 9881). Zero dependencies, Node.js 24 with OpenSSL 3.5. Each with its test suite and a negative control.

This commit is contained in:
Aere Network 2026-09-29 16:24:04 +03:00
commit 7d814d1bb8
24 changed files with 7004 additions and 0 deletions

15
LICENSE Normal file
View File

@ -0,0 +1,15 @@
MIT License
Copyright (c) 2026 Aere Network
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

30
README.md Normal file
View File

@ -0,0 +1,30 @@
# Aere Quantum
Self-hosted post-quantum infrastructure from Aere Network. Three components, each a few files with **no dependencies**:
Node.js 24 and the OpenSSL 3.5 it ships with (`node:crypto`), nothing from a package registry.
| component | what it does |
|---|---|
| [`pq-gateway/`](pq-gateway/) | a TLS 1.3 terminating proxy in front of any HTTP service, with the hybrid key exchange X25519MLKEM768: `hybrid-only` refuses a classical client at the handshake, `hybrid-preferred` keeps it working; optional client authentication with ML-DSA certificates |
| [`pq-kms/`](pq-kms/) | a transit-style key management service where every key is hybrid: X25519 + ML-KEM-768 for encryption, Ed25519 + ML-DSA-65 for signatures (both halves required); versions, rotation, rewrap, data keys, a chained audit log; the root key from the environment or sealed by an HSM through PKCS#11 |
| [`pq-pki/`](pq-pki/) | a private certificate authority for ML-DSA (X.509 v3, RFC 9881): root and issuing CAs, leaf certificates, revocation lists, and a strict chain verifier compared against OpenSSL |
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
## How each is checked
Every component ships its test suite and a negative control: the control plants a real defect in a copy of the code, one at a time,
and requires the named test to fail for the named reason; a planting that cannot be applied, or that breaks the build instead of the
test, counts as a failure of the control. Results measured on 2026-09-29 (Node.js 24.14.1, OpenSSL 3.5.5):
| component | tests | negative control |
|---|---|---|
| pq-gateway | 33/33 (`node proba-pq-gateway.mjs`) | 33/33 (`bash proba-pq-gateway-control-negativ.sh`) |
| pq-kms | 62/62 (`node test/proba.mjs`); HSM root on SoftHSM2 + OpenSC 20/20 (`test/proba-hsm.mjs`, Linux); sealed-file trust rules 7/7 (`test/proba-hsm-incredere.mjs`) | 16/16 (`node test/control-negativ.mjs`); sealed-file rules 2/2 in this repository (`test/control-negativ-hsm-incredere.mjs`) |
| pq-pki | 27/27 (`node test/proba.mjs`), each verdict compared with OpenSSL 3.5 | 22/22 (`node test/control-negativ.mjs`) |
Test names and control messages are in Romanian; identifiers, error codes, messages and documentation are in English.
## Licence
MIT, see [LICENSE](LICENSE). Files: 24 (pq-gateway 6, pq-kms 10, pq-pki 6).

24
pq-gateway/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Aere Cloud, Quantum Security Gateway. Imagine minima: Node 24 si un singur fisier, fara nicio dependinta.
# NECONSTRUITA si netrasa pe laptopul de dezvoltare (2026-09-25): versiunea OpenSSL din imagine e NEMASURATA aici.
# Gateway-ul o verifica singur la pornire (refuza sub OpenSSL 3.5) si o scrie in randul 'listening' din jurnal.
#
# docker build -t aere-pq-gateway .
# docker run --rm -p 443:8443 \
# -e AERE_PQGW_MODE=hybrid-preferred -e AERE_PQGW_UPSTREAM=http://app:8080 \
# -v /cale/catre/certificate:/etc/aere-pq-gateway:ro aere-pq-gateway
#
# Ruleaza ca utilizatorul neprivilegiat 'node' (uid 1000) din imaginea oficiala: cheia montata trebuie sa fie
# citibila de el, altfel gateway-ul refuza sa porneasca cu "cannot read KEY (EACCES)".
FROM node:24-alpine
WORKDIR /app
COPY pq-gateway.mjs /app/pq-gateway.mjs
ENV AERE_PQGW_LISTEN=:8443 \
AERE_PQGW_CERT=/etc/aere-pq-gateway/fullchain.pem \
AERE_PQGW_KEY=/etc/aere-pq-gateway/privkey.pem
USER node
EXPOSE 8443
STOPSIGNAL SIGTERM
CMD ["node", "/app/pq-gateway.mjs"]

205
pq-gateway/README.md Normal file
View File

@ -0,0 +1,205 @@
# Aere Quantum Security Gateway
A TLS 1.3 terminating reverse proxy that you place in front of any HTTP service you already run. It offers the
hybrid post-quantum key exchange **X25519MLKEM768** (ML-KEM-768 combined with X25519) to your clients and forwards
their requests to your service over HTTP/1.1.
It is one file, `pq-gateway.mjs`, with no dependencies: it uses only the modules built into Node.js. It needs
Node.js 24 linked to OpenSSL 3.5 or later, and it checks this at startup and refuses to start otherwise.
## What it protects, and what it does not
The hybrid key exchange protects the **confidentiality** of traffic between your clients and the gateway against an
adversary who records it today and hopes to decrypt it later with a quantum computer ("harvest now, decrypt later").
The session keys depend on both ML-KEM-768 and X25519, so an attacker has to break both.
It does **not**:
- **Make server authentication post-quantum by itself.** With an ECDSA or RSA certificate the server still authenticates
with a classical signature: the protection is for the key exchange, not the signature. Serve an **ML-DSA chain** (for
example issued by `../pq-pki`, the Aere post-quantum CA) and the handshake is post-quantum end to end: the key exchange
is `X25519MLKEM768` and the server signs with `mldsa65` (measured with `openssl s_client`, test `(s)`). Set
`AERE_PQGW_REQUIRE_PQ_AUTH=true` and the gateway refuses to start unless every served certificate has an ML-DSA key
and an ML-DSA signature; the status endpoint reports `certificate.authentication` as `post-quantum`, `mixed` or
`classical`. Clients must support ML-DSA signatures (OpenSSL 3.5 does; many browsers do not yet).
- **Protect the hop from the gateway to your upstream** when `UPSTREAM` is `http://`. That hop is plain HTTP. Keep it
on a trusted network (same host, same private network, loopback) or use an `https://` upstream. When the upstream is
`https://`, the gateway also prefers X25519MLKEM768 on that hop, but it makes no claim about which group was used
there.
- **Speak HTTP/2 or HTTP/3.** The gateway advertises only `http/1.1` over ALPN. A client that offers `h2` and
`http/1.1` gets `http/1.1`.
- **Report the negotiated group per connection in `hybrid-preferred` mode.** The Node.js TLS server API does not expose
which group a connection negotiated (`getEphemeralKeyInfo()` is client-side only). The gateway therefore does not
count or claim "hybrid connections". See the modes below.
## Modes
Set with `AERE_PQGW_MODE`. There is no default: you choose explicitly.
| mode | groups offered | what it guarantees |
|---|---|---|
| `hybrid-only` | `X25519MLKEM768` | Every accepted connection used the hybrid key exchange. The guarantee is structural: it is the only group offered, so a client that does not support it fails the TLS handshake and is counted as refused (`no shared group`). |
| `hybrid-preferred` | `X25519MLKEM768`, then `X25519` and `P-256` | Clients that support X25519MLKEM768 get it, including clients that sent only an X25519 key share first: the gateway asks for the hybrid group with a HelloRetryRequest. Clients that do not support it fall back to X25519 or P-256 and are served. No per-connection guarantee. |
The preference in `hybrid-preferred` is written with OpenSSL 3.5 group tuples (`X25519MLKEM768/X25519:P-256`). A flat
list (`X25519MLKEM768:X25519:P-256`) is not enough: in our tests a client that listed X25519 first stayed on X25519
with a flat list, and moved to X25519MLKEM768 only with the tuple form.
Both modes require TLS 1.3 (`minVersion` is always `TLSv1.3`). A TLS 1.2 client is refused and counted as
`unsupported protocol`.
Choose `hybrid-only` when you control the clients (service to service, your own apps). In our tests, both the
OpenSSL 3.5 command line client and a Node.js 24 client linked to OpenSSL 3.5 negotiated X25519MLKEM768 with their
default settings. Choose `hybrid-preferred` for public traffic where older clients must keep working. Check your own
client population before you switch a public endpoint to `hybrid-only`.
## Running it
```sh
AERE_PQGW_MODE=hybrid-preferred \
AERE_PQGW_LISTEN=:8443 \
AERE_PQGW_CERT=/etc/aere-pq-gateway/fullchain.pem \
AERE_PQGW_KEY=/etc/aere-pq-gateway/privkey.pem \
AERE_PQGW_UPSTREAM=http://localhost:8080 \
node pq-gateway.mjs
```
It writes one JSON line per event to stdout (`listening`, `warning`, `upstream_error`, `shutdown`, `stopped`). The
`listening` line includes the mode, the groups, the certificate SHA-256 fingerprint and the Node.js and OpenSSL
versions it is running with.
### Docker
The `Dockerfile` builds a minimal image from `node:24-alpine` that runs as the unprivileged `node` user and listens on
port 8443 inside the container.
```sh
docker build -t aere-pq-gateway .
docker run --rm -p 443:8443 \
-e AERE_PQGW_MODE=hybrid-preferred \
-e AERE_PQGW_UPSTREAM=http://app:8080 \
-v /path/to/certs:/etc/aere-pq-gateway:ro \
aere-pq-gateway
```
The mounted private key must be readable by the container user (uid 1000), otherwise the gateway refuses to start
with `cannot read KEY (EACCES)`. The OpenSSL version inside the image is printed on the `listening` line; the gateway
refuses to start if it is older than 3.5. Not yet measured by us: a build of this image and the OpenSSL version it
carries (the test suite runs the gateway directly on Node.js 24 with OpenSSL 3.5.5).
## Configuration
Environment variables take precedence over the optional JSON file named by `AERE_PQGW_CONFIG`
(see `pq-gateway.example.json`). An unknown key in the file is an error, so a typo cannot be silently ignored.
| variable | JSON key | default | meaning |
|---|---|---|---|
| `AERE_PQGW_MODE` | `mode` | required | `hybrid-only` or `hybrid-preferred` |
| `AERE_PQGW_CERT` | `cert` | required | PEM certificate chain (leaf first) |
| `AERE_PQGW_KEY` | `key` | required | PEM private key; must match the leaf certificate or the gateway refuses to start |
| `AERE_PQGW_UPSTREAM` | `upstream` | required | `http://host:port[/base]` or `https://host:port[/base]`; no credentials, query or fragment |
| `AERE_PQGW_UPSTREAM_CA` | `upstreamCa` | system CAs | PEM CA bundle for an `https://` upstream with a private CA |
| `AERE_PQGW_LISTEN` | `listen` | `:8443` | `host:port`, `[ipv6]:port`, or `:port` for all interfaces |
| `AERE_PQGW_CONNECT_TIMEOUT_MS` | `connectTimeoutMs` | `5000` | time to connect to the upstream (TCP, plus TLS for https) |
| `AERE_PQGW_REQUEST_TIMEOUT_MS` | `requestTimeoutMs` | `60000` | time for the upstream to start its response after the request was fully sent |
| `AERE_PQGW_HANDSHAKE_TIMEOUT_MS` | `handshakeTimeoutMs` | `10000` | time for a client to complete the TLS handshake |
| `AERE_PQGW_SHUTDOWN_GRACE_MS` | `shutdownGraceMs` | `10000` | how long in-flight requests may finish after SIGTERM |
| `AERE_PQGW_MAX_HEADER_SIZE` | `maxHeaderSize` | Node.js default (16 KiB) | maximum size of request headers; larger requests get `431` |
| `AERE_PQGW_TRUST_FORWARDED` | `trustForwarded` | `false` | append to an incoming `X-Forwarded-For` (and keep `Forwarded`) instead of replacing it; enable only behind another proxy you trust |
| `AERE_PQGW_PRESERVE_HOST` | `preserveHost` | `false` | send the client's `Host` to the upstream instead of the upstream's own host |
| `AERE_PQGW_REQUIRE_PQ_AUTH` | `requirePqAuth` | `false` | refuse to start unless the served chain is ML-DSA end to end (keys and signatures) |
## What the upstream receives
- The method, path and query as sent by the client (prefixed with the base path of `UPSTREAM`, if any).
- The request body as a stream: it is forwarded while it arrives, never buffered whole.
- All end-to-end headers. Hop-by-hop headers are removed: `Connection` and every header it names, `Keep-Alive`,
`Proxy-*`, `TE`, `Trailer`, `Transfer-Encoding` and `Upgrade` (except on the upgrade path, see below). `Expect` is
handled by the gateway and not forwarded.
- `X-Forwarded-For` and `X-Real-IP` (the client address, from the socket), `X-Forwarded-Proto: https`,
`X-Forwarded-Host` (the client's `Host`), and `x-aere-pq-gateway: <mode>`. Values a client sends under these
names are dropped, so they cannot be forged (unless you enable `AERE_PQGW_TRUST_FORWARDED` for
`X-Forwarded-For`). Client-sent `X-Client-*` and `X-SSL-*` headers (the names TLS-terminating proxies use to
hand over a client certificate identity) are dropped as well: this gateway does not authenticate clients, so
an upstream must not see such headers as if it had.
Upgrade requests (WebSocket) are tunnelled in both directions after the upstream answers `101`. If the upstream
declines the upgrade, its response is passed back and the connection is closed.
If the upstream cannot be reached, the client gets `502` with a short JSON body
(`{"error":"bad_gateway",...}`). An `https://` upstream whose certificate cannot be verified is also `502`: there is
no option to skip verification; use `AERE_PQGW_UPSTREAM_CA` for a private CA. If the upstream does not connect
(including its TLS handshake) or does not start responding in time, the client gets `504`
(`{"error":"gateway_timeout",...}`). A request never hangs waiting for an upstream that does not answer. Once the
upstream has started its response, the gateway applies no idle timeout to the body, so long-lived streams such as
server-sent events keep working. A client that disconnects before the response is not counted as an upstream error.
## Status endpoint
`GET /.well-known/aere-pq-gateway` is answered by the gateway itself and never forwarded. It is public, so it contains
nothing secret: no private key and no file paths. It returns:
- `mode`, `groupsOffered`, `groupPreference`, `minTlsVersion`, `alpn`
- `guarantee.hybridKeyExchangeOnEveryConnection`: `true` only in `hybrid-only`, with the reason in `guarantee.basis`
- `negotiatedGroupPerConnection`: states that the negotiated group is not reported per connection, and why
- `certificate`: SHA-256 fingerprint, subject and expiry of the served certificate; `authentication` (`post-quantum`,
`mixed`, `classical`) and `chain`, each served certificate with its public key and signature algorithm
- `upstream`: only the scheme and whether that hop is encrypted (not the address)
- `runtime`: Node.js and OpenSSL versions
- `counters`: `connectionsAccepted`, `handshakesRefused` by reason (`no shared group`, `unsupported protocol`,
`no shared cipher`, `no shared signature algorithm` (a client that offers no ML-DSA signature algorithm
against an ML-DSA chain, which is most browsers today), `handshake timeout`, `other`), `handshakesRefusedByCode`
(the OpenSSL error code),
`requestsForwarded`, `upgradesTunneled`, `responses502`, `responses504`, `statusRequests`
## Verifying it yourself
With OpenSSL 3.5 or later, independent of this gateway:
```sh
openssl s_client -groups X25519MLKEM768 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
```
Look for the line:
```
Negotiated TLS1.3 group: X25519MLKEM768
```
In `hybrid-only` mode, a classical-only client must be refused:
```sh
openssl s_client -groups X25519 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
```
This fails with `alert handshake failure` (alert number 40). Note that `s_client` still prints a
`Negotiated TLS1.3 group:` line on failure, with the value `<NULL>`, and `Cipher is (NONE)`.
If you test with a Node.js client: on Node.js 24 with OpenSSL 3.5, `tlsSocket.getEphemeralKeyInfo()` returns an empty
object `{}` for the hybrid group (and `{ type: 'ECDH', name: 'X25519', ... }` for X25519), so it cannot name
X25519MLKEM768. Use `openssl s_client` or read the `key_share` extension of the ServerHello from a packet capture
(group `0x11ec` is X25519MLKEM768, `0x001d` is X25519).
## Shutdown
On `SIGTERM` or `SIGINT` the gateway stops accepting connections, lets in-flight requests finish (responses sent during
shutdown carry `Connection: close`), closes remaining connections and tunnels after `AERE_PQGW_SHUTDOWN_GRACE_MS`, and
exits on its own.
## Tests
`proba-pq-gateway.mjs` starts everything locally on the loopback interface with ports chosen by the system: a
self-signed certificate made with the `openssl` command line tool in a temporary directory, an echo upstream, and the
gateway as a child process configured through its environment, exactly as you would run it. It reads the negotiated
group from the wire (the ServerHello `key_share`), with `openssl s_client` as a second, independent client. Negative
tests check the reason for each refusal, not only that it failed.
`proba-pq-gateway-control-negativ.sh` proves the tests can fail: it disables the gateway's safeguards one at a time
in a copy (for example, letting `hybrid-only` also offer X25519, removing the TLS 1.3 minimum, forwarding hop-by-hop
headers, putting the private key in the status output) and requires the matching test to fail while the whole suite
still runs.
```sh
node proba-pq-gateway.mjs
bash proba-pq-gateway-control-negativ.sh
```

View File

@ -0,0 +1,13 @@
{
"listen": ":8443",
"cert": "/etc/aere-pq-gateway/fullchain.pem",
"key": "/etc/aere-pq-gateway/privkey.pem",
"upstream": "http://app:8080",
"mode": "hybrid-preferred",
"connectTimeoutMs": 5000,
"requestTimeoutMs": 60000,
"handshakeTimeoutMs": 10000,
"shutdownGraceMs": 10000,
"trustForwarded": false,
"preserveHost": false
}

584
pq-gateway/pq-gateway.mjs Normal file
View File

@ -0,0 +1,584 @@
#!/usr/bin/env node
// pq-gateway.mjs, Aere Cloud, Quantum Security Gateway (randul 1 din lista Cloud).
//
// Ce face: termina TLS 1.3 cu schimb de chei hibrid X25519MLKEM768 (ML-KEM-768 + X25519) si trimite cererile HTTP/1.1
// mai departe la serviciul clientului (UPSTREAM), cu corpul in flux. Numai module node:*, fara nicio dependinta; cere
// Node legat de OpenSSL >= 3.5 (verificat la pornire, altfel refuza sa porneasca).
//
// Cele doua moduri:
// hybrid-only ofera NUMAI X25519MLKEM768. Un client fara el e refuzat la strangerea de mana. Garantia e
// STRUCTURALA: nu exista alt grup pe care sa se poata negocia.
// hybrid-preferred X25519MLKEM768, apoi X25519 si P-256. Preferinta e scrisa cu TUPLE OpenSSL 3.5
// ('X25519MLKEM768/X25519:P-256'): un client care stie hibridul dar a trimis doar o cota X25519
// primeste un HelloRetryRequest si ajunge pe hibrid. Masurat 2026-09-25 pe Node 24.14.1 +
// OpenSSL 3.5.5: cu lista PLATA 'X25519MLKEM768:X25519:P-256' acelasi client ramanea pe X25519,
// deci "intai hibridul" scris ca lista plata nu era adevarat.
//
// Ce NU afirma, si de ce: pe partea de SERVER Node nu expune grupul negociat (getEphemeralKeyInfo e numai pentru
// client), deci gateway-ul nu spune per conexiune ce grup s-a folosit. In hybrid-only nu are nevoie (e singurul grup);
// in hybrid-preferred punctul de stare spune explicit ca nu poate spune, si nu numara conexiuni "hibride".
//
// Configuratie: variabile AERE_PQGW_* (lista in CHEI, mai jos, si in README), optional un fisier JSON dat prin
// AERE_PQGW_CONFIG; mediul are prioritate fata de fisier. O cheie necunoscuta in fisier e o eroare, nu o tacere.
//
// Oprire: SIGTERM/SIGINT inchid ascultatorul, lasa cererile in curs sa se termine (cel mult AERE_PQGW_SHUTDOWN_GRACE_MS),
// pun process.exitCode si lasa bucla de evenimente sa se goleasca; niciun process.exit() dupa I/O.
import https from 'node:https';
import http from 'node:http';
import fs from 'node:fs';
import net from 'node:net';
import crypto from 'node:crypto';
import { pathToFileURL } from 'node:url';
export const VERSIUNE = '1.0.0';
export const CALE_STARE = '/.well-known/aere-pq-gateway';
// Grupurile oferite, pe mod. '/' separa tuple de preferinta (OpenSSL 3.5), ':' separa grupuri in acelasi tuplu.
export const GRUPURI = {
'hybrid-only': 'X25519MLKEM768',
'hybrid-preferred': 'X25519MLKEM768/X25519:P-256',
};
// Antete hop-by-hop (RFC 9110 7.6.1), plus Proxy-* scoase dupa prefix si orice nume enumerat in Connection.
const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
// Antete pe care le scrie gateway-ul insusi; ce trimite clientul sub aceste nume nu trece, altfel s-ar putea falsifica.
// Expect: 100-continue e deja raspuns de serverul Node inaintea handler-ului, deci nu se mai cere o data upstream-ului.
const PROPRII = new Set(['x-aere-pq-gateway', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'forwarded', 'host', 'expect']);
// Antete de IDENTITATE pe care un upstream le crede venite de la un proxy de incredere (nginx realip, cadre web, mTLS terminat
// in fata): clientul nu are voie sa le scrie. Masurat 2026-09-25 (revizuire): X-Real-IP si X-Client-Cert treceau neatinse.
const PREFIXE_IDENTITATE = ['x-client-', 'x-ssl-'];
// Variabila de mediu -> cheia din fisierul JSON.
const CHEI = {
AERE_PQGW_LISTEN: 'listen',
AERE_PQGW_CERT: 'cert',
AERE_PQGW_KEY: 'key',
AERE_PQGW_UPSTREAM: 'upstream',
AERE_PQGW_UPSTREAM_CA: 'upstreamCa',
AERE_PQGW_MODE: 'mode',
AERE_PQGW_CONNECT_TIMEOUT_MS: 'connectTimeoutMs',
AERE_PQGW_REQUEST_TIMEOUT_MS: 'requestTimeoutMs',
AERE_PQGW_HANDSHAKE_TIMEOUT_MS: 'handshakeTimeoutMs',
AERE_PQGW_SHUTDOWN_GRACE_MS: 'shutdownGraceMs',
AERE_PQGW_MAX_HEADER_SIZE: 'maxHeaderSize',
AERE_PQGW_TRUST_FORWARDED: 'trustForwarded',
AERE_PQGW_PRESERVE_HOST: 'preserveHost',
AERE_PQGW_REQUIRE_PQ_AUTH: 'requirePqAuth',
};
function intreg(nume, v, min, max) {
const n = typeof v === 'number' ? v : Number(String(v).trim());
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${nume} must be an integer between ${min} and ${max}`);
return n;
}
function boolean(nume, v) {
if (typeof v === 'boolean') return v;
const s = String(v).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(s)) return true;
if (['0', 'false', 'no', 'off', ''].includes(s)) return false;
throw new Error(`${nume} must be true or false`);
}
export function citesteAdresa(s) {
const t = String(s).trim();
let m = t.match(/^\[([0-9a-fA-F:.]+)\]:(\d+)$/);
if (m) return { host: m[1], port: intreg('LISTEN port', m[2], 0, 65535) };
m = t.match(/^([^:[\]]*):(\d+)$/);
if (m) return { host: m[1] || undefined, port: intreg('LISTEN port', m[2], 0, 65535) };
throw new Error('LISTEN must be host:port, [ipv6]:port or :port');
}
// Valorile se taie (trim) la citire: un fisier de mediu cu CRLF nu are voie sa lipeasca un CR in cale sau in URL.
export function citesteConfig(env = process.env) {
let f = {};
if (env.AERE_PQGW_CONFIG && String(env.AERE_PQGW_CONFIG).trim()) {
let brut;
try { brut = fs.readFileSync(String(env.AERE_PQGW_CONFIG).trim(), 'utf8'); } catch (e) { throw new Error(`cannot read AERE_PQGW_CONFIG (${e.code || 'error'})`); }
try { f = JSON.parse(brut); } catch { throw new Error('AERE_PQGW_CONFIG is not valid JSON'); }
if (!f || typeof f !== 'object' || Array.isArray(f)) throw new Error('AERE_PQGW_CONFIG must contain a JSON object');
const stiute = new Set(Object.values(CHEI));
for (const k of Object.keys(f)) if (!stiute.has(k)) throw new Error(`unknown key in config file: ${k}`);
}
const v = (numeEnv, implicit) => {
const e = env[numeEnv];
if (e !== undefined && String(e).trim() !== '') return String(e).trim();
const k = CHEI[numeEnv];
if (f[k] !== undefined && f[k] !== null) return typeof f[k] === 'string' ? f[k].trim() : f[k];
return implicit;
};
const mode = v('AERE_PQGW_MODE', null);
if (!mode) throw new Error('MODE is required: hybrid-only or hybrid-preferred');
if (!Object.prototype.hasOwnProperty.call(GRUPURI, mode)) throw new Error('MODE must be hybrid-only or hybrid-preferred');
const cert = v('AERE_PQGW_CERT', null);
const key = v('AERE_PQGW_KEY', null);
if (!cert) throw new Error('CERT is required (PEM certificate chain)');
if (!key) throw new Error('KEY is required (PEM private key)');
const upstream = v('AERE_PQGW_UPSTREAM', null);
if (!upstream) throw new Error('UPSTREAM is required (http://... or https://...)');
let u;
try { u = new URL(upstream); } catch { throw new Error('UPSTREAM is not a valid URL'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('UPSTREAM must start with http:// or https://');
if (u.username || u.password) throw new Error('UPSTREAM must not contain credentials');
if (u.search || u.hash) throw new Error('UPSTREAM must not contain a query string or fragment');
const { host, port } = citesteAdresa(v('AERE_PQGW_LISTEN', ':8443'));
const maxHeaderSize = v('AERE_PQGW_MAX_HEADER_SIZE', null);
return {
host, port, cert, key, mode,
upstream: u.href,
upstreamCa: v('AERE_PQGW_UPSTREAM_CA', null),
connectTimeoutMs: intreg('CONNECT_TIMEOUT_MS', v('AERE_PQGW_CONNECT_TIMEOUT_MS', 5000), 100, 600000),
requestTimeoutMs: intreg('REQUEST_TIMEOUT_MS', v('AERE_PQGW_REQUEST_TIMEOUT_MS', 60000), 100, 3600000),
handshakeTimeoutMs: intreg('HANDSHAKE_TIMEOUT_MS', v('AERE_PQGW_HANDSHAKE_TIMEOUT_MS', 10000), 100, 600000),
shutdownGraceMs: intreg('SHUTDOWN_GRACE_MS', v('AERE_PQGW_SHUTDOWN_GRACE_MS', 10000), 0, 600000),
maxHeaderSize: maxHeaderSize === null ? null : intreg('MAX_HEADER_SIZE', maxHeaderSize, 1024, 1048576),
trustForwarded: boolean('TRUST_FORWARDED', v('AERE_PQGW_TRUST_FORWARDED', false)),
preserveHost: boolean('PRESERVE_HOST', v('AERE_PQGW_PRESERVE_HOST', false)),
requirePqAuth: boolean('REQUIRE_PQ_AUTH', v('AERE_PQGW_REQUIRE_PQ_AUTH', false)),
};
}
export function grupuriOferite(mode) {
return GRUPURI[mode].split(/[:/]/).filter(Boolean);
}
// Motivul unei strangeri de mana esuate, din codul OpenSSL pe care il pune Node pe eroare.
// Masurat 2026-09-25: client numai X25519 fata de hybrid-only -> ERR_SSL_NO_SUITABLE_KEY_SHARE;
// client TLS 1.2 -> ERR_SSL_UNSUPPORTED_PROTOCOL.
export function motivRefuz(e) {
const c = String((e && e.code) || '');
if (c === 'ERR_SSL_NO_SUITABLE_KEY_SHARE' || c === 'ERR_SSL_NO_SHARED_GROUPS' || c === 'ERR_SSL_NO_SUITABLE_GROUPS') return 'no shared group';
if (c === 'ERR_SSL_UNSUPPORTED_PROTOCOL') return 'unsupported protocol';
if (c === 'ERR_SSL_NO_SHARED_CIPHER') return 'no shared cipher';
// cu un lant ML-DSA, refuzul cel mai frecvent: clientul nu ofera un algoritm de semnatura post-cuantic (browserele de azi)
if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') return 'no shared signature algorithm';
if (c === 'ERR_TLS_HANDSHAKE_TIMEOUT') return 'handshake timeout';
return 'other';
}
// Scoate antetele hop-by-hop dintr-o lista bruta [nume, valoare, nume, valoare, ...].
export function filtreazaAnteturi(brute) {
const numite = new Set();
for (let i = 0; i < brute.length; i += 2) {
if (brute[i].toLowerCase() !== 'connection') continue;
for (const t of String(brute[i + 1]).split(',')) { const n = t.trim().toLowerCase(); if (n) numite.add(n); }
}
const out = [];
for (let i = 0; i < brute.length; i += 2) {
const n = brute[i].toLowerCase();
if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;
out.push(brute[i], brute[i + 1]);
}
return out;
}
// Pe un ascultator dual-stack o adresa IPv4 vine ca ::ffff:a.b.c.d; upstream-ul primeste forma IPv4.
export function adresaClient(brut) {
const s = String(brut || '');
return s.toLowerCase().startsWith('::ffff:') && net.isIPv4(s.slice(7)) ? s.slice(7) : s;
}
function versiuneOpenssl() {
const m = String(process.versions.openssl || '').match(/^(\d+)\.(\d+)/);
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
}
function scrieJurnal(o) {
process.stdout.write(JSON.stringify({ time: new Date().toISOString(), ...o }) + '\n');
}
// ------------------------------------------------------------------------------------------------ autentificarea post-cuantica
// Schimbul de chei hibrid apara secretul sesiunii; AUTENTIFICAREA serverului o da semnatura din CertificateVerify (cu cheia
// frunzei) si semnaturile de pe lant. Un lant e "post-quantum" numai daca FIECARE certificat servit are cheie ML-DSA si e semnat
// ML-DSA (algoritmul exterior citit din DER); radacina nu se serveste, deci semnatura ei de pe ultimul certificat servit e citita
// ca oricare alta. Altfel "classical" (nimic ML-DSA) sau "mixed". Cu REQUIRE_PQ_AUTH gateway-ul refuza sa porneasca fara el.
const ML_DSA_OID = new Map([['0609608648016503040311', 'ML-DSA-44'], ['0609608648016503040312', 'ML-DSA-65'], ['0609608648016503040313', 'ML-DSA-87']]);
function lungimeDer(b, o) {
let l = b[o + 1], h = 2;
if (l & 0x80) { const n = l & 0x7f; if (n < 1 || n > 4) throw new Error('bad DER length'); l = 0; for (let i = 0; i < n; i++) l = l * 256 + b[o + 2 + i]; h = 2 + n; }
return { h, l };
}
function algSemnaturaDer(der) {
// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm AlgorithmIdentifier, signatureValue }
const top = lungimeDer(der, 0);
let o = top.h;
const tbs = lungimeDer(der, o); o += tbs.h + tbs.l;
const alg = lungimeDer(der, o);
const oid = der.subarray(o + alg.h, o + alg.h + alg.l);
const e = lungimeDer(oid, 0);
return ML_DSA_OID.get(Buffer.from(oid.subarray(0, e.h + e.l)).toString('hex')) || 'classical';
}
export function analizaLant(certPem) {
const blocuri = String(certPem).match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
const lant = blocuri.map((p) => {
const x = new crypto.X509Certificate(p);
const cheie = String(x.publicKey.asymmetricKeyType || 'unknown').toUpperCase();
return { subject: x.subject, publicKeyAlgorithm: cheie, signatureAlgorithm: algSemnaturaDer(x.raw) };
});
const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) && /^ML-DSA-/.test(c.signatureAlgorithm);
const autentificare = lant.length && lant.every(pq) ? 'post-quantum' : lant.some((c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) || /^ML-DSA-/.test(c.signatureAlgorithm)) ? 'mixed' : 'classical';
return { lant, autentificare };
}
export function pornesteGateway(cfg, { jurnal = scrieJurnal } = {}) {
const [maj, min] = versiuneOpenssl();
if (maj < 3 || (maj === 3 && min < 5)) throw new Error(`OpenSSL >= 3.5 is required for X25519MLKEM768; this Node is linked to OpenSSL ${process.versions.openssl}`);
let certPem, cheiePem;
try { certPem = fs.readFileSync(cfg.cert); } catch (e) { throw new Error(`cannot read CERT (${e.code || 'error'})`); }
try { cheiePem = fs.readFileSync(cfg.key); } catch (e) { throw new Error(`cannot read KEY (${e.code || 'error'})`); }
let x509, cheie;
try { x509 = new crypto.X509Certificate(certPem); } catch { throw new Error('CERT does not contain a PEM certificate'); }
try { cheie = crypto.createPrivateKey(cheiePem); } catch { throw new Error('KEY does not contain a readable private key'); }
if (!x509.checkPrivateKey(cheie)) throw new Error('KEY does not match the first certificate in CERT');
let lantCert;
try { lantCert = analizaLant(certPem); } catch { throw new Error('CERT contains a certificate that cannot be read'); }
if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum') {
const rau = lantCert.lant.find((c) => !/^ML-DSA-/.test(c.publicKeyAlgorithm) || !/^ML-DSA-/.test(c.signatureAlgorithm));
throw new Error(`REQUIRE_PQ_AUTH is set but the served chain is ${lantCert.autentificare}: "${rau ? rau.subject.replace(/\n/g, ', ') : '?'}" has a ${rau ? rau.publicKeyAlgorithm : '?'} key signed with ${rau ? rau.signatureAlgorithm : '?'}; issue an ML-DSA chain (for example with aere-pq-pki)`);
}
const sus = new URL(cfg.upstream);
const susHttps = sus.protocol === 'https:';
const modul = susHttps ? https : http;
const susPort = sus.port || (susHttps ? 443 : 80);
const prefix = sus.pathname.replace(/\/+$/, '');
let caSus = null;
if (susHttps && cfg.upstreamCa) {
try { caSus = fs.readFileSync(cfg.upstreamCa); } catch (e) { throw new Error(`cannot read UPSTREAM_CA (${e.code || 'error'})`); }
}
// Si drumul catre upstream, cand e https, prefera hibridul; nu se afirma nimic despre el in stare.
const agent = susHttps
? new https.Agent({ keepAlive: true, minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...(caSus ? { ca: caSus } : {}) })
: new http.Agent({ keepAlive: true });
const caUpgrade = caSus ? { ca: caSus } : {};
const c = {
conexiuniAcceptate: 0,
refuzuri: { 'no shared group': 0, 'unsupported protocol': 0, 'no shared cipher': 0, 'no shared signature algorithm': 0, 'handshake timeout': 0, other: 0 },
cereriTrimise: 0, tuneluri: 0, r502: 0, r504: 0, cereriStare: 0,
};
const refuzuriPeCod = new Map();
const pornitLa = new Date().toISOString();
const tuneluriDeschise = new Set();
let seInchide = false;
function corpStare() {
const hibridOnly = cfg.mode === 'hybrid-only';
return {
service: 'aere-pq-gateway',
version: VERSIUNE,
mode: cfg.mode,
groupsOffered: grupuriOferite(cfg.mode),
groupPreference: GRUPURI[cfg.mode],
minTlsVersion: 'TLSv1.3',
alpn: ['http/1.1'],
guarantee: hibridOnly
? {
hybridKeyExchangeOnEveryConnection: true,
basis: 'structural: X25519MLKEM768 is the only key exchange group this listener offers, so a handshake that does not use it fails',
}
: {
hybridKeyExchangeOnEveryConnection: false,
basis: 'X25519MLKEM768 is preferred and requested with a HelloRetryRequest when the client supports it; clients without it fall back to X25519 or P-256',
},
negotiatedGroupPerConnection: 'not reported: the Node.js TLS server API does not expose the negotiated group (getEphemeralKeyInfo is client-side only), so this gateway makes no per-connection claim',
certificate: { sha256: x509.fingerprint256, subject: x509.subject, notAfter: x509.validTo,
authentication: lantCert.autentificare, chain: lantCert.lant },
upstream: { scheme: susHttps ? 'https' : 'http', encrypted: susHttps },
runtime: { node: process.version, openssl: process.versions.openssl },
startedAt: pornitLa,
counters: {
connectionsAccepted: c.conexiuniAcceptate,
handshakesRefused: { ...c.refuzuri },
handshakesRefusedByCode: Object.fromEntries(refuzuriPeCod),
requestsForwarded: c.cereriTrimise,
upgradesTunneled: c.tuneluri,
responses502: c.r502,
responses504: c.r504,
statusRequests: c.cereriStare,
},
};
}
function esteCaleaStarii(url) {
const q = url.indexOf('?');
return (q === -1 ? url : url.slice(0, q)) === CALE_STARE;
}
function raspundeJson(req, res, cod, obj, inchide = false) {
const corp = JSON.stringify(obj) + '\n';
const h = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp), 'cache-control': 'no-store' };
if (inchide || seInchide) h.connection = 'close';
res.writeHead(cod, h);
res.end(req.method === 'HEAD' ? undefined : corp);
}
// Raspuns scris direct pe un socket (drumul de upgrade nu are ServerResponse).
function scrieBrut(socket, cod, obj) {
const corp = JSON.stringify(obj) + '\n';
socket.end(`HTTP/1.1 ${cod} ${http.STATUS_CODES[cod]}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(corp)}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n${corp}`);
}
function anteturiCatreUpstream(req, upgrade) {
const h = filtreazaAnteturi(req.rawHeaders);
const out = [];
let xffVechi = null;
for (let i = 0; i < h.length; i += 2) {
const n = h[i].toLowerCase();
if (n === 'x-forwarded-for') {
if (cfg.trustForwarded) xffVechi = xffVechi ? `${xffVechi}, ${h[i + 1]}` : h[i + 1];
continue;
}
if (n === 'forwarded' && cfg.trustForwarded) { out.push(h[i], h[i + 1]); continue; }
if (PROPRII.has(n) || PREFIXE_IDENTITATE.some((p) => n.startsWith(p))) continue;
out.push(h[i], h[i + 1]);
}
const hostOriginal = req.headers.host;
out.push('Host', cfg.preserveHost && hostOriginal ? hostOriginal : sus.host);
const ip = adresaClient(req.socket.remoteAddress);
out.push('X-Forwarded-For', xffVechi ? `${xffVechi}, ${ip}` : ip);
out.push('X-Real-IP', ip);
out.push('X-Forwarded-Proto', 'https');
if (hostOriginal) out.push('X-Forwarded-Host', hostOriginal);
out.push('x-aere-pq-gateway', cfg.mode);
if (upgrade) out.push('Connection', 'Upgrade', 'Upgrade', String(req.headers.upgrade));
else if (req.headers['transfer-encoding'] !== undefined) out.push('Transfer-Encoding', 'chunked');
return out;
}
function optiuniUpstream(req, anteturi, peUpgrade) {
return {
protocol: sus.protocol, hostname: sus.hostname, port: susPort,
method: req.method, path: req.url === '*' ? '*' : prefix + req.url,
headers: anteturi, setHost: false,
agent: peUpgrade ? false : agent,
...(peUpgrade && susHttps ? { minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...caUpgrade } : {}),
};
}
// Doua cronometre: conectarea la upstream (TCP, plus TLS cand e https) si raspunsul lui, numarat de cand cererea a
// fost trimisa intreaga. Oricare expira -> 504. Un socket refolosit din bazin nu mai are faza de conectare.
// Un upstream poate raspunde INAINTE sa fi primit tot corpul; atunci 'finish' vine dupa raspuns si nu mai porneste
// nimic. Masurat 2026-09-25: fara garda, un raspuns care curgea inca a fost taiat la REQUEST_TIMEOUT dupa 'finish'.
function puneCronometre(upReq) {
let tConectare = null, tRaspuns = null, raspunsPrimit = false;
const expirat = (tip) => Object.assign(new Error(`upstream ${tip} timeout`), { aereTimeout: tip });
upReq.on('socket', (s) => {
if (!s.connecting) return;
tConectare = setTimeout(() => upReq.destroy(expirat('connect')), cfg.connectTimeoutMs);
s.once(susHttps ? 'secureConnect' : 'connect', () => { clearTimeout(tConectare); tConectare = null; });
});
upReq.on('finish', () => {
if (raspunsPrimit) return;
tRaspuns = setTimeout(() => upReq.destroy(expirat('response')), cfg.requestTimeoutMs);
});
const opreste = () => { raspunsPrimit = true; clearTimeout(tConectare); clearTimeout(tRaspuns); };
upReq.on('close', opreste);
return { opreste };
}
function corpEroare(cod, e) {
if (cod === 504) return { error: 'gateway_timeout', detail: e && e.aereTimeout === 'connect' ? 'upstream connect timeout' : 'upstream response timeout' };
return { error: 'bad_gateway', detail: 'upstream unreachable or connection failed' };
}
function numaraEroare(e) {
const cod = e && e.aereTimeout ? 504 : 502;
if (cod === 504) c.r504++; else c.r502++;
jurnal({ event: 'upstream_error', status: cod, code: (e && (e.aereTimeout || e.code)) || 'unknown' });
return cod;
}
function laCerere(req, res) {
if (esteCaleaStarii(req.url)) {
c.cereriStare++;
req.resume();
if (req.method !== 'GET' && req.method !== 'HEAD') { res.setHeader('allow', 'GET, HEAD'); return raspundeJson(req, res, 405, { error: 'method_not_allowed' }); }
return raspundeJson(req, res, 200, corpStare());
}
if (!(req.url.startsWith('/') || (req.method === 'OPTIONS' && req.url === '*'))) {
req.resume();
return raspundeJson(req, res, 400, { error: 'bad_request', detail: 'request target must be origin-form' }, true);
}
const te = req.headers['transfer-encoding'];
if (te !== undefined && String(te).trim().toLowerCase() !== 'chunked') {
req.resume();
return raspundeJson(req, res, 501, { error: 'not_implemented', detail: 'only chunked transfer-encoding is supported' }, true);
}
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, false), false));
c.cereriTrimise++;
const cron = puneCronometre(upReq);
let raspuns = false;
upReq.on('response', (upRes) => {
cron.opreste();
if (raspuns) { upRes.resume(); return; }
raspuns = true;
const h = filtreazaAnteturi(upRes.rawHeaders);
if (seInchide) h.push('Connection', 'close');
try {
res.writeHead(upRes.statusCode, upRes.statusMessage, h);
} catch (e) {
jurnal({ event: 'upstream_bad_response', code: e.code || 'error' });
upRes.destroy();
res.destroy();
return;
}
upRes.on('error', () => res.destroy());
upRes.on('close', () => { if (!upRes.complete) res.destroy(); });
upRes.pipe(res);
});
upReq.on('error', (e) => {
if (raspuns) { res.destroy(); return; }
raspuns = true;
req.unpipe(upReq);
req.resume();
// Clientul a plecat inainte de raspuns: cererea catre upstream a fost taiata de noi, nu e o eroare a upstream-ului.
// Masurat 2026-09-25: fara garda asta, doua plecari de client au iesit "responses502 +2".
if (res.headersSent || res.destroyed) { res.destroy(); return; }
const cod = numaraEroare(e);
raspundeJson(req, res, cod, corpEroare(cod, e), true);
});
res.on('close', () => { if (!res.writableFinished) upReq.destroy(); });
req.pipe(upReq);
}
const server = https.createServer({
key: cheiePem,
cert: certPem,
minVersion: 'TLSv1.3',
ecdhCurve: GRUPURI[cfg.mode],
ALPNProtocols: ['http/1.1'],
handshakeTimeout: cfg.handshakeTimeoutMs,
...(cfg.maxHeaderSize ? { maxHeaderSize: cfg.maxHeaderSize } : {}),
}, laCerere);
server.on('secureConnection', () => { c.conexiuniAcceptate++; });
server.on('tlsClientError', (e, sock) => {
c.refuzuri[motivRefuz(e)]++;
const cod = String((e && e.code) || 'UNKNOWN').slice(0, 64);
const cheieCod = refuzuriPeCod.has(cod) || refuzuriPeCod.size < 31 ? cod : '(other codes)';
refuzuriPeCod.set(cheieCod, (refuzuriPeCod.get(cheieCod) || 0) + 1);
if (sock && !sock.destroyed) sock.destroy();
});
server.on('upgrade', (req, socket, head) => {
socket.on('error', () => {});
if (esteCaleaStarii(req.url) || !req.url.startsWith('/')) { scrieBrut(socket, 400, { error: 'bad_request', detail: 'upgrade not allowed on this path' }); return; }
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, true), true));
c.cereriTrimise++;
const cron = puneCronometre(upReq);
let gata = false;
upReq.on('upgrade', (upRes, upSock, upHead) => {
cron.opreste();
gata = true;
upSock.on('error', () => {});
if (socket.destroyed) { upSock.destroy(); return; }
c.tuneluri++;
const h = filtreazaAnteturi(upRes.rawHeaders);
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || 'Switching Protocols'}`];
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
linii.push('Connection: Upgrade', `Upgrade: ${upRes.headers.upgrade || req.headers.upgrade}`);
socket.write(linii.join('\r\n') + '\r\n\r\n');
if (upHead && upHead.length) socket.write(upHead);
if (head && head.length) upSock.write(head);
tuneluriDeschise.add(socket);
socket.setTimeout(0);
socket.setNoDelay(true);
upSock.setNoDelay(true);
const inchide = () => { tuneluriDeschise.delete(socket); socket.destroy(); upSock.destroy(); };
socket.on('close', inchide);
upSock.on('close', inchide);
upSock.pipe(socket);
socket.pipe(upSock);
});
// Upstream-ul nu a acceptat upgrade-ul: raspunsul lui ajunge la client, apoi conexiunea se inchide.
upReq.on('response', (upRes) => {
cron.opreste();
gata = true;
const h = filtreazaAnteturi(upRes.rawHeaders);
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || ''}`];
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
linii.push('Connection: close');
socket.write(linii.join('\r\n') + '\r\n\r\n');
upRes.on('error', () => socket.destroy());
upRes.pipe(socket);
});
upReq.on('error', (e) => {
if (gata) { socket.destroy(); return; }
gata = true;
if (socket.destroyed) return; // clientul a plecat: nu se numara ca eroare de upstream
const cod = numaraEroare(e);
if (!socket.destroyed) scrieBrut(socket, cod, corpEroare(cod, e));
});
socket.on('close', () => { if (!gata) upReq.destroy(); });
upReq.end();
});
function opreste(graceMs) {
seInchide = true;
return new Promise((resolve) => {
const t = setTimeout(() => {
server.closeAllConnections();
for (const s of tuneluriDeschise) s.destroy();
}, graceMs);
server.close(() => { clearTimeout(t); agent.destroy(); resolve(); });
server.closeIdleConnections();
});
}
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(cfg.port, cfg.host, () => {
server.off('error', reject);
server.on('error', (e) => jurnal({ event: 'server_error', code: e.code || 'error' }));
resolve({ server, adresa: server.address(), opreste, stare: corpStare });
});
});
}
async function main() {
let cfg;
try { cfg = citesteConfig(process.env); } catch (e) {
process.stderr.write(`aere-pq-gateway: configuration error: ${e.message}\n`);
process.exitCode = 2;
return;
}
let gw;
try { gw = await pornesteGateway(cfg); } catch (e) {
process.stderr.write(`aere-pq-gateway: failed to start: ${e.message}\n`);
process.exitCode = 1;
return;
}
const s = gw.stare();
scrieJurnal({
event: 'listening', address: gw.adresa.address, port: gw.adresa.port, mode: cfg.mode,
groups: GRUPURI[cfg.mode], minTlsVersion: 'TLSv1.3', certificateSha256: s.certificate.sha256,
upstreamScheme: s.upstream.scheme, node: process.version, openssl: process.versions.openssl,
});
if (!s.upstream.encrypted) scrieJurnal({ event: 'warning', detail: 'UPSTREAM is plain http: the hop from this gateway to the upstream is not encrypted; keep it on a trusted network or use https' });
let oprire = false;
const laSemnal = (semnal) => {
if (oprire) return;
oprire = true;
scrieJurnal({ event: 'shutdown', signal: semnal, graceMs: cfg.shutdownGraceMs });
gw.opreste(cfg.shutdownGraceMs).then(() => {
scrieJurnal({ event: 'stopped' });
process.exitCode = 0;
});
};
process.on('SIGTERM', () => laSemnal('SIGTERM'));
process.on('SIGINT', () => laSemnal('SIGINT'));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();

View File

@ -0,0 +1,144 @@
#!/bin/bash
# Controlul negativ al probelor gateway-ului PQ (proba-pq-gateway.mjs). Paznicii din pq-gateway.mjs se ORBESC pe rand,
# intr-o COPIE a dosarului (originalul nu se atinge), si proba tinta TREBUIE sa iasa ROSIE pe numele ei. Suita trebuie
# sa fi RULAT intreaga, altfel verdictul e STRICAT, nu rosu. La urma suita neatinsa trebuie sa fie verde.
# bash aerenew/cloud-gateway/pq-gateway/proba-pq-gateway-control-negativ.sh
# Iesire: un rand pe paznic, apoi "CONTROL NEGATIV: N treceri, M esecuri"; cod nenul la orice esec sau STRICAT.
set -u
AICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W=$(mktemp -d); trap 'rm -rf "$W"' EXIT
# probele (s) folosesc autoritatea de certificare PQ de langa gateway; copia nu o are, deci calea ei vine din mediu
export AERE_PQ_PKI="$(cd "$AICI/../pq-pki" && pwd)/pki.mjs"
if command -v cygpath >/dev/null 2>&1; then AERE_PQ_PKI="$(cygpath -w "$AERE_PQ_PKI")"; fi
for f in pq-gateway.mjs proba-pq-gateway.mjs pq-gateway.example.json; do
cp "$AICI/$f" "$W/$f"
cmp -s "$AICI/$f" "$W/$f" || { echo "STRICAT: copia lui $f difera de original"; exit 3; }
done
PROBE=$(grep -c "^await test(" "$AICI/proba-pq-gateway.mjs")
[ "$PROBE" -gt 0 ] || { echo "STRICAT: nu am gasit nicio proba in proba-pq-gateway.mjs"; exit 3; }
cale_nod() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else echo "$1"; fi; }
treceri=0; esecuri=0
# $1 vechi, $2 nou. Ancora trebuie sa apara EXACT o data, altfel plantarea e un esec al controlului, nu un verde.
planteaza() {
AERE_VECHI="$1" AERE_NOU="$2" node -e "
const fs = require('fs'); const p = process.argv[1]; const t = fs.readFileSync(p, 'utf8'); const a = process.env.AERE_VECHI;
const n = t.split(a).length - 1;
if (n !== 1) { console.log(' ancora apare de ' + n + ' ori'); process.exitCode = 3; }
else fs.writeFileSync(p, t.split(a).join(process.env.AERE_NOU));" "$(cale_nod "$W/pq-gateway.mjs")"
}
# $1 descrierea paznicului orbit, $2 eticheta probei tinta, de ex. (g)
masoara() {
if cmp -s "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs"; then
echo " $1: STRICAT (plantarea nu a schimbat nimic)"; esecuri=$((esecuri+1)); return
fi
if ! cmp -s "$AICI/proba-pq-gateway.mjs" "$W/proba-pq-gateway.mjs"; then
echo " $1: STRICAT (proba din copie difera de original)"; esecuri=$((esecuri+1)); return
fi
( cd "$W" && node proba-pq-gateway.mjs > "$W/out.txt" 2>&1 )
local cod=$?
local rulate; rulate=$(grep -c "^ OK \|^ ESEC \|^ -- " "$W/out.txt")
local tinta; tinta=$(grep "^ ESEC " "$W/out.txt" | grep -cF "ESEC $2 ")
local rosii; rosii=$(grep -c "^ ESEC " "$W/out.txt")
if [ "$rulate" -ne "$PROBE" ]; then
echo " $1: STRICAT (au rulat $rulate probe din $PROBE, cod $cod)"; tail -3 "$W/out.txt" | cut -c1-220; esecuri=$((esecuri+1))
elif [ "$cod" -eq 0 ]; then
echo " $1: CONTROL NEGATIV CAZUT (suita a iesit 0)"; esecuri=$((esecuri+1))
elif [ "$tinta" -ge 1 ]; then
echo " $1: ROSU cum trebuia ($rosii esecuri in suita, intre ele $2)"
grep "^ ESEC " "$W/out.txt" | grep -F "ESEC $2 " | cut -c1-330 | sed 's/^/ /'
treceri=$((treceri+1))
else
echo " $1: CONTROL NEGATIV CAZUT (proba $2 a ramas verde; au picat: $(grep '^ ESEC ' "$W/out.txt" | cut -c8-12 | tr '\n' ' '))"; esecuri=$((esecuri+1))
fi
cp "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs"
cmp -s "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs" || { echo "STRICAT: copia nu s-a putut reface"; exit 3; }
}
control() { # $1 descriere, $2 tinta, $3 ancora, $4 inlocuire
if planteaza "$3" "$4"; then masoara "$1" "$2"; else echo " $1: STRICAT (plantarea a esuat)"; esecuri=$((esecuri+1)); fi
}
control "autentificarea raportata mereu post-quantum" "(s2)" \
"const autentificare = lant.length && lant.every(pq) ? 'post-quantum'" \
"const autentificare = lant.length && (lant.every(pq) || Boolean(Number('1'))) ? 'post-quantum'"
control "REQUIRE_PQ_AUTH ignorat" "(s3)" \
" if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum') {" \
" if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum' && Boolean(Number('0'))) {"
control "numai cheile lantului judecate, nu si semnaturile" "(s3)" \
"const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) && /^ML-DSA-/.test(c.signatureAlgorithm);" \
"const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm);"
control "X-Real-IP al clientului crezut (revizuirea 2026-09-25, A8)" "(e3)" \
"'x-forwarded-host', 'x-real-ip', 'forwarded'" \
"'x-forwarded-host', 'forwarded'"
control "X-Client-* si X-SSL-* ale clientului trec la upstream (A8)" "(e3)" \
"PREFIXE_IDENTITATE.some((p) => n.startsWith(p))" \
"PREFIXE_IDENTITATE.some((p) => n.startsWith(p) && Boolean(Number('0')))"
control "refuzul pe algoritmul de semnatura numarat ca other (A9)" "(g3)" \
"if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') return 'no shared signature algorithm';" \
"if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM' && Boolean(Number('0'))) return 'no shared signature algorithm';"
control "hybrid-only ofera si X25519" "(g)" \
"'hybrid-only': 'X25519MLKEM768'," "'hybrid-only': 'X25519MLKEM768:X25519',"
control "motivul refuzului nu mai e numarat (totul 'other')" "(g)" \
"export function motivRefuz(e) {" "export function motivRefuz(e) { if (!process.env.AERE_NU) return 'other';"
control "minVersion TLSv1.3 scos (ramane implicitul Node, TLSv1.2)" "(h)" \
"minVersion: 'TLSv1.3'," ""
control "antetele hop-by-hop nescoase" "(e)" \
"if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;" "if (Boolean(process.env.AERE_NU)) continue;"
control "numele enumerate in Connection nescoase" "(e)" \
"if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;" "if (HOP.has(n) || n.startsWith('proxy-')) continue;"
control "X-Forwarded-For al clientului crezut" "(e)" \
"if (cfg.trustForwarded) xffVechi =" "if (!process.env.AERE_NU) xffVechi ="
control "x-aere-pq-gateway nepus (cel falsificat de client trece)" "(e)" \
"out.push('x-aere-pq-gateway', cfg.mode);" "if (Boolean(process.env.AERE_NU)) out.push('x-aere-pq-gateway', cfg.mode);"
control "cheia privata inclusa in stare" "(f)" \
"certificate: { sha256: x509.fingerprint256," "privateKey: cheiePem.toString('utf8'), certificate: { sha256: x509.fingerprint256,"
control "starea trimisa la upstream in loc sa fie servita" "(f)" \
"if (esteCaleaStarii(req.url)) {" "if (Boolean(process.env.AERE_NU) && esteCaleaStarii(req.url)) {"
control "upstream cazut: niciun raspuns (agatare)" "(i)" \
"raspundeJson(req, res, cod, corpEroare(cod, e), true);" "if (Boolean(process.env.AERE_NU)) raspundeJson(req, res, cod, corpEroare(cod, e), true);"
control "timeout raportat ca 502" "(i2)" \
"const cod = e && e.aereTimeout ? 504 : 502;" "const cod = 502;"
control "hybrid-preferred pretinde hibrid garantat" "(j)" \
"hybridKeyExchangeOnEveryConnection: false," "hybridKeyExchangeOnEveryConnection: true,"
control "hybrid-preferred ca lista plata (fara tuplu, fara HelloRetryRequest)" "(j2)" \
"'hybrid-preferred': 'X25519MLKEM768/X25519:P-256'," "'hybrid-preferred': 'X25519MLKEM768:X25519:P-256',"
control "corpul bufferat intreg inainte de trimitere" "(k)" \
"req.pipe(upReq);" "(async () => { const b = []; for await (const x of req) b.push(x); upReq.end(Buffer.concat(b)); })().catch(() => upReq.destroy());"
control "tunelul upgrade numai intr-un sens" "(d)" \
"upSock.pipe(socket);" ""
control "upgrade refuzat de upstream trimis fara Connection: close" "(d2)" \
"linii.push('Connection: close');" ""
control "upgrade catre upstream cazut: niciun raspuns pe socket" "(i3)" \
"if (!socket.destroyed) scrieBrut(socket, cod, corpEroare(cod, e));" "if (Boolean(process.env.AERE_NU)) scrieBrut(socket, cod, corpEroare(cod, e));"
control "drumul catre upstream https fara preferinta hibrida" "(q)" \
"ecdhCurve: GRUPURI['hybrid-preferred'], ...(caSus ?" "ecdhCurve: 'X25519', ...(caSus ?"
control "upgrade catre upstream https fara CA-ul configurat" "(d3)" \
"ecdhCurve: GRUPURI['hybrid-preferred'], ...caUpgrade }" "ecdhCurve: GRUPURI['hybrid-preferred'] }"
control "plecarea clientului numarata ca 502 (cerere)" "(s)" \
"if (res.headersSent || res.destroyed) { res.destroy(); return; }" ""
control "plecarea clientului numarata ca 502 (upgrade)" "(s)" \
"if (socket.destroyed) return; //" "if (Boolean(process.env.AERE_NU)) return; //"
control "raspunsul care curge taiat de cronometrul pornit la 'finish'" "(k2)" \
"if (raspunsPrimit) return;" "if (Boolean(process.env.AERE_NU)) return;"
control "adresa ::ffff: trimisa neschimbata in X-Forwarded-For" "(e2)" \
"return s.toLowerCase().startsWith('::ffff:') && net.isIPv4(s.slice(7)) ? s.slice(7) : s;" "return s;"
control "certificatul upstream-ului https nu se mai verifica" "(q2)" \
"new https.Agent({ keepAlive: true," "new https.Agent({ rejectUnauthorized: false, keepAlive: true,"
control "fara cronometru de conectare la upstream" "(r)" \
"tConectare = setTimeout(() => upReq.destroy(expirat('connect')), cfg.connectTimeoutMs);" "tConectare = null;"
control "oprirea taie cererile in curs" "(l)" \
"server.closeIdleConnections();" "server.closeAllConnections();"
( cd "$AICI" && node proba-pq-gateway.mjs > "$W/final.txt" 2>&1 )
codf=$?
rulatef=$(grep -c "^ OK \|^ ESEC \|^ -- " "$W/final.txt")
okf=$(grep -c "^ OK " "$W/final.txt")
if [ "$codf" -eq 0 ] && [ "$rulatef" -eq "$PROBE" ] && [ "$okf" -eq "$PROBE" ]; then
echo " suita neatinsa: VERDE ($(tail -1 "$W/final.txt"))"; treceri=$((treceri+1))
else
echo " suita neatinsa: NU e verde (cod $codf, $okf din $PROBE OK, rulate $rulatef)"; grep "^ ESEC \|^ -- \|STRICAT" "$W/final.txt" | cut -c1-220; esecuri=$((esecuri+1))
fi
echo "CONTROL NEGATIV: $treceri treceri, $esecuri esecuri"
[ "$esecuri" -eq 0 ] || exit 1

View File

@ -0,0 +1,965 @@
// Probele gateway-ului PQ (pq-gateway.mjs). Totul pe 127.0.0.1, pe porturi alese de sistem; nicio retea reala.
// node aerenew/cloud-gateway/pq-gateway/proba-pq-gateway.mjs
//
// Ce porneste: un certificat auto-semnat facut cu openssl CLI intr-un dosar temporar, un upstream HTTP local care
// ecouieste metoda, calea, antetele si sha256-ul corpului, si gateway-ul ca PROCES COPIL (node pq-gateway.mjs cu
// mediul lui), exact cum l-ar rula un client. Singura exceptie e (l), oprirea curata, care cheama functia exportata in
// proces: pe Windows un semnal trimis unui copil il omoara fara handler, deci semnalul adevarat nu se poate livra aici.
//
// Grupul negociat se citeste in trei feluri, fiindca fiecare singur ar fi putut minti:
// 1. DE PE FIR: un robinet TCP intre client si gateway retine octetii, iar ServerHello (in clar in TLS 1.3) se
// desface pana la extensia key_share (0x0033); 0x11ec = X25519MLKEM768, 0x001d = X25519.
// 2. openssl s_client, un client independent de codul nostru si de Node ("Negotiated TLS1.3 group").
// 3. getEphemeralKeyInfo() din clientul Node, TIPARIT exact. Masurat 2026-09-25 pe Node 24.14.1 + OpenSSL 3.5.5:
// intoarce {} pentru grupul hibrid (si {type:'ECDH',name:'X25519'} pentru cel clasic), deci API-ul NU numeste
// hibridul; proba cere doar ca el sa nu pretinda un grup clasic, iar numele il ia de pe fir.
//
// Drumul gateway -> upstream https se citeste la fel, cu un al doilea robinet in fata unui upstream https local.
//
// Iesire: un rand " OK ", " ESEC " sau " -- " (nemasurat) pe proba, apoi "N treceri, M esecuri". Cod 1 la esec,
// 3 cand pregatirea a cazut (STRICAT: nicio proba nu a rulat).
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import net from 'node:net';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import readline from 'node:readline';
import { EventEmitter } from 'node:events';
import { spawn, spawnSync, execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const GATEWAY = path.join(AICI, 'pq-gateway.mjs');
const CALE_STARE = '/.well-known/aere-pq-gateway';
const GAZDA = '127.0.0.1';
const HIBRID = 0x11ec, X25519 = 0x001d;
const NUME_GRUP = { 0x11ec: 'X25519MLKEM768', 0x11eb: 'SecP256r1MLKEM768', 0x11ed: 'SecP384r1MLKEM1024', 0x001d: 'X25519', 0x001e: 'X448', 0x0017: 'P-256', 0x0018: 'P-384', 0x0019: 'P-521' };
const numeGrup = (g) => (g === null || g === undefined ? 'nimic' : NUME_GRUP[g] || '0x' + g.toString(16).padStart(4, '0'));
const HRR = Buffer.from('cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c', 'hex');
const dormi = (ms) => new Promise((r) => setTimeout(r, ms));
let treceri = 0, esecuri = 0, nemasurate = 0;
const NEMASURAT = Symbol('nemasurat');
async function test(nume, fn) {
try {
const r = await fn();
if (r && r[NEMASURAT]) { console.log(` -- ${nume}: NEMASURAT, ${r.motiv}`); nemasurate++; return; }
console.log(` OK ${nume}${r ? ` [${r}]` : ''}`);
treceri++;
} catch (e) {
console.log(` ESEC ${nume}: ${String(e && e.message ? e.message : e).replace(/\s+/g, ' ').slice(0, 400)}`);
esecuri++;
}
}
function cere(cond, mesaj) { if (!cond) throw new Error(mesaj); }
// ---------------------------------------------------------------- pregatirea
const copii = [];
const deInchis = [];
let TMP = null;
function curata() {
for (const p of copii) { try { p.kill(); } catch { /* deja mort */ } }
for (const s of socluriTacute) s.destroy();
for (const s of deInchis) { try { s.close(); } catch { /* deja inchis */ } }
if (TMP) { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ramane in temp */ } }
}
process.on('exit', curata);
// Paznic: o proba agatata nu are voie sa tina suita la nesfarsit. Dupa ultimul rand, un rest agatat (un socket
// ramas deschis) nu mai schimba verdictul, doar il spune.
let suitaTerminata = false;
const paznic = setTimeout(() => {
if (suitaTerminata) { console.log('(nota: bucla nu s-a golit singura in 240 s; iesire fortata cu verdictul de mai sus)'); process.exit(process.exitCode || 0); }
console.log('STRICAT: suita a depasit 240 s');
curata();
process.exit(3);
}, 240000);
paznic.unref();
function gasesteOpenssl() {
const candidati = [process.env.AERE_OPENSSL, 'openssl', 'C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', '/usr/bin/openssl'].filter(Boolean);
for (const bin of candidati) {
try {
const v = execFileSync(bin, ['version'], { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
const m = v.match(/OpenSSL (\d+)\.(\d+)/);
if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 5))) return { bin, versiune: v };
} catch { /* urmatorul */ }
}
return null;
}
// Robinet TCP: tine octetii fiecarei conexiuni in ambele sensuri, ca grupul sa se citeasca de pe fir.
function robinet(portTinta) {
const conexiuni = [];
const srv = net.createServer((cl) => {
const rec = { c2s: [], s2c: [] };
conexiuni.push(rec);
const sv = net.connect(portTinta, GAZDA);
cl.on('data', (b) => rec.c2s.push(b));
sv.on('data', (b) => rec.s2c.push(b));
cl.pipe(sv);
sv.pipe(cl);
const gata = () => { cl.destroy(); sv.destroy(); };
cl.on('error', gata); sv.on('error', gata); cl.on('close', gata); sv.on('close', gata);
});
deInchis.push(srv);
return new Promise((r) => srv.listen(0, GAZDA, () => r({ port: srv.address().port, conexiuni })));
}
// Mesajele de handshake in clar (inregistrari de tip 22 dinaintea primei inregistrari criptate, tip 23).
function mesajeHandshake(bucati) {
const b = Buffer.concat(bucati);
const flux = [];
let i = 0;
while (i + 5 <= b.length) {
const tip = b[i], lung = b.readUInt16BE(i + 3);
if (i + 5 + lung > b.length || tip === 23) break;
if (tip === 22) flux.push(b.subarray(i + 5, i + 5 + lung));
i += 5 + lung;
}
const h = Buffer.concat(flux);
const mesaje = [];
let p = 0;
while (p + 4 <= h.length) {
const lung = h.readUIntBE(p + 1, 3);
if (p + 4 + lung > h.length) break;
mesaje.push({ tip: h[p], corp: h.subarray(p + 4, p + 4 + lung) });
p += 4 + lung;
}
return mesaje;
}
function extensii(c, p, sfarsit) {
const m = new Map();
while (p + 4 <= sfarsit) { const t = c.readUInt16BE(p), l = c.readUInt16BE(p + 2); m.set(t, c.subarray(p + 4, p + 4 + l)); p += 4 + l; }
return m;
}
function clientHello(c) {
let p = 2 + 32;
p += 1 + c[p];
p += 2 + c.readUInt16BE(p);
p += 1 + c[p];
const lung = c.readUInt16BE(p); p += 2;
const ext = extensii(c, p, p + lung);
const grupuri = [], cote = [], versiuni = [];
const sg = ext.get(0x000a);
if (sg) for (let q = 2; q + 2 <= 2 + sg.readUInt16BE(0); q += 2) grupuri.push(sg.readUInt16BE(q));
const ks = ext.get(0x0033);
if (ks) { let q = 2; const sf = 2 + ks.readUInt16BE(0); while (q + 4 <= sf) { cote.push(ks.readUInt16BE(q)); q += 4 + ks.readUInt16BE(q + 2); } }
const sv = ext.get(0x002b);
if (sv) for (let q = 1; q + 2 <= 1 + sv[0]; q += 2) versiuni.push(sv.readUInt16BE(q));
else versiuni.push(c.readUInt16BE(0));
return { grupuri, cote, versiuni };
}
function serverHello(c) {
let p = 2;
const aleator = c.subarray(p, p + 32); p += 32;
p += 1 + c[p];
p += 3;
const lung = c.readUInt16BE(p); p += 2;
const ext = extensii(c, p, p + lung);
const ks = ext.get(0x0033), sv = ext.get(0x002b);
return { hrr: aleator.equals(HRR), grup: ks ? ks.readUInt16BE(0) : null, versiune: sv ? sv.readUInt16BE(0) : c.readUInt16BE(0) };
}
function citesteFir(rec) {
return {
ch: mesajeHandshake(rec.c2s).filter((m) => m.tip === 1).map((m) => clientHello(m.corp)),
sh: mesajeHandshake(rec.s2c).filter((m) => m.tip === 2).map((m) => serverHello(m.corp)),
};
}
const descrieSH = (sh) => sh.map((s) => (s.hrr ? `HRR->${numeGrup(s.grup)}` : `SH ${numeGrup(s.grup)}`)).join(', ') || 'niciun ServerHello';
let CERT_PEM, CHEIE_PEM, CALE_CERT, CALE_CHEIE, OPENSSL, AMPRENTA;
function cerere({ port, cale = '/', metoda = 'GET', anteturi = {}, corp = null, curbe = 'X25519MLKEM768', maxVersion, timeoutMs = 8000, ca = CERT_PEM }) {
return new Promise((resolve, reject) => {
let eki = null, protocol = null;
const r = https.request({
host: GAZDA, port, path: cale, method: metoda, headers: anteturi, ca, servername: 'localhost', agent: false,
...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}),
}, (res) => {
const b = [];
res.on('data', (x) => b.push(x));
res.on('end', () => resolve({ status: res.statusCode, anteturi: res.headers, corp: Buffer.concat(b), eki, protocol }));
res.on('error', reject);
});
r.on('socket', (s) => s.once('secureConnect', () => { eki = s.getEphemeralKeyInfo(); protocol = s.getProtocol(); }));
r.setTimeout(timeoutMs, () => r.destroy(new Error(`clientul a asteptat ${timeoutMs} ms fara raspuns`)));
r.on('error', reject);
r.end(corp || undefined);
});
}
const json = (r) => { try { return JSON.parse(r.corp.toString('utf8')); } catch { throw new Error(`corpul nu e JSON (status ${r.status}): ${r.corp.toString('utf8').slice(0, 120)}`); } };
// Numai strangerea de mana; intoarce ok sau codul erorii din client.
function strangere({ port, curbe, maxVersion, alpn }) {
return new Promise((resolve) => {
const s = tls.connect({
host: GAZDA, port, ca: CERT_PEM, servername: 'localhost',
...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}), ...(alpn ? { ALPNProtocols: alpn } : {}),
}, () => {
const r = { ok: true, eki: s.getEphemeralKeyInfo(), protocol: s.getProtocol(), alpn: s.alpnProtocol };
s.end();
resolve(r);
});
s.setTimeout(8000, () => s.destroy(new Error('strangerea nu s-a terminat in 8 s')));
s.on('error', (e) => resolve({ ok: false, cod: e.code, mesaj: e.message }));
});
}
// O cerere de upgrade scrisa de mana; intoarce tot ce a venit pana la inchiderea conexiunii.
function upgradeBrut(port, cale, ms = 6000) {
return new Promise((resolve, reject) => {
const cheie = crypto.randomBytes(16).toString('base64');
const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => {
s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});
let acc = Buffer.alloc(0);
const t = setTimeout(() => { s.destroy(); reject(new Error(`conexiunea nu s-a inchis in ${ms} ms; primit: '${acc.toString('latin1').split('\r\n')[0] || 'nimic'}'`)); }, ms);
s.on('data', (b) => { acc = Buffer.concat([acc, b]); });
s.on('error', () => {});
s.on('close', () => {
clearTimeout(t);
const txt = acc.toString('latin1');
const k = txt.indexOf('\r\n\r\n');
resolve({ antet: k === -1 ? txt : txt.slice(0, k), corp: k === -1 ? '' : txt.slice(k + 4) });
});
});
}
async function stare(port, ca) { const r = await cerere({ port, cale: CALE_STARE, ...(ca ? { ca } : {}) }); cere(r.status === 200, `starea a raspuns ${r.status}`); return json(r); }
async function asteaptaStare(port, pred, ms = 4000, ca) {
const t0 = Date.now();
let s;
while (Date.now() - t0 < ms) { s = await stare(port, ca); if (pred(s)) return s; await dormi(100); }
return s;
}
function sClient(argumente, intrare = '', ms = 20000) {
return new Promise((resolve) => {
const p = spawn(OPENSSL, ['s_client', ...argumente], { stdio: ['pipe', 'pipe', 'pipe'] });
let o = '', e = '';
const t = setTimeout(() => p.kill(), ms);
p.stdout.on('data', (b) => { o += b; });
p.stderr.on('data', (b) => { e += b; });
p.on('close', (cod) => { clearTimeout(t); resolve({ cod, o, e }); });
p.on('error', (err) => { clearTimeout(t); resolve({ cod: -1, o, e: e + String(err.message) }); });
p.stdin.on('error', () => {});
p.stdin.end(intrare);
});
}
function mediuGateway(extra) {
const env = { ...process.env };
for (const k of Object.keys(env)) if (k.startsWith('AERE_PQGW_')) delete env[k];
return { ...env, ...extra };
}
function pornesteGw(nume, extra) {
return new Promise((resolve, reject) => {
const p = spawn(process.execPath, [GATEWAY], { env: mediuGateway(extra), stdio: ['ignore', 'pipe', 'pipe'] });
copii.push(p);
let err = '';
const jurnal = [];
p.stderr.on('data', (b) => { err += b; });
const t = setTimeout(() => reject(new Error(`${nume}: nu a raportat 'listening' in 10 s; stderr: ${err.slice(0, 300)}`)), 10000);
readline.createInterface({ input: p.stdout }).on('line', (l) => {
let j = null;
try { j = JSON.parse(l); } catch { return; }
jurnal.push(j);
if (j.event === 'listening') { clearTimeout(t); resolve({ p, port: j.port, nume, jurnal, pornire: j }); }
});
p.on('exit', (cod) => { clearTimeout(t); reject(new Error(`${nume} a iesit cu ${cod}: ${err.slice(0, 300)}`)); });
});
}
// Upstream-ul de proba: ecou pentru orice cerere, 101 + salut + ecou pentru upgrade (sau 403 pe /ws-refuz).
const jurnalUpstream = [];
const evUp = new EventEmitter();
// Raspunde INAINTE sa fi primit tot corpul, apoi curge inca ~2,8 s dupa sfarsitul cererii (ca un flux SSE).
function devreme(req, res) {
res.writeHead(200, { 'content-type': 'text/plain' });
res.write('inceput\n');
req.resume();
req.on('end', () => {
let n = 0;
const t = setInterval(() => {
if (res.destroyed) { clearInterval(t); return; }
n++;
if (n <= 6) res.write(`bucata ${n}\n`);
else { clearInterval(t); res.end('gata\n'); }
}, 400);
});
}
function ecou(req, res) {
if (req.url === '/devreme') return devreme(req, res);
const h = crypto.createHash('sha256');
let n = 0;
const id = req.headers['x-proba-id'];
req.on('data', (b) => { if (n === 0 && id) evUp.emit(`primul:${id}`); n += b.length; h.update(b); });
req.on('end', async () => {
const u = new URL(req.url, 'http://upstream.invalid');
jurnalUpstream.push({ method: req.method, url: req.url });
if (u.pathname === '/slow') await dormi(Number(u.searchParams.get('ms')) || 1000);
if (res.destroyed) return;
const corp = JSON.stringify({ method: req.method, url: req.url, rawHeaders: req.rawHeaders, headers: req.headers, bodySha256: h.digest('hex'), bodyBytes: n });
res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp) });
res.end(corp);
});
}
function laUpgrade(req, sock, head) {
jurnalUpstream.push({ method: req.method, url: req.url, upgrade: req.headers.upgrade, headers: req.headers, tls: Boolean(sock.encrypted) });
sock.on('error', () => {});
if (req.url === '/ws-refuz') { sock.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: close\r\n\r\nnu-ws'); return; }
if (req.url === '/ws-lent') { setTimeout(() => sock.destroy(), 2500); return; }
const accept = crypto.createHash('sha1').update(String(req.headers['sec-websocket-key']) + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
sock.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
sock.write('SALUT-DE-LA-UPSTREAM\n');
if (head && head.length) sock.write(head);
sock.on('data', (d) => sock.write(d));
}
function pornesteUpstreamHttps() {
const srv = https.createServer({ key: CHEIE_PEM, cert: CERT_PEM }, ecou);
srv.on('upgrade', laUpgrade);
deInchis.push(srv);
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
}
// Accepta TCP si nu spune nimic: o strangere TLS catre el nu se termina niciodata.
const socluriTacute = new Set();
function pornesteTacut() {
const srv = net.createServer((s) => { socluriTacute.add(s); s.on('error', () => {}); s.on('close', () => socluriTacute.delete(s)); });
deInchis.push(srv);
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
}
function pornesteUpstream() {
const srv = http.createServer(ecou);
srv.on('upgrade', laUpgrade);
deInchis.push(srv);
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
}
async function portLiber() {
const s = net.createServer();
await new Promise((r) => s.listen(0, GAZDA, r));
const p = s.address().port;
await new Promise((r) => s.close(r));
return p;
}
let GH, GP, GM, GS, GSX, GT, TH, TP, TS, PORT_UP;
try {
const o = gasesteOpenssl();
if (!o) throw new Error('nu am gasit openssl >= 3.5 (AERE_OPENSSL, PATH sau Git for Windows)');
OPENSSL = o.bin;
TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pqgw-proba-'));
CALE_CERT = path.join(TMP, 'cert.pem');
CALE_CHEIE = path.join(TMP, 'cheie.pem');
// execFileSync cu cai native (Node pe Windows da cai Windows), deci nicio conversie MSYS pe drum.
execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', CALE_CHEIE, '-out', CALE_CERT,
'-days', '2', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'], { stdio: ['ignore', 'pipe', 'pipe'] });
CERT_PEM = fs.readFileSync(CALE_CERT);
CHEIE_PEM = fs.readFileSync(CALE_CHEIE, 'utf8');
AMPRENTA = new crypto.X509Certificate(CERT_PEM).fingerprint256;
PORT_UP = await pornesteUpstream();
const portUpHttps = await pornesteUpstreamHttps();
const portTacut = await pornesteTacut();
TS = await robinet(portUpHttps);
const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only' };
[GH, GP, GM, GS, GSX, GT] = await Promise.all([
pornesteGw('gateway hybrid-only', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUEST_TIMEOUT_MS: '1500' }),
pornesteGw('gateway hybrid-preferred', { ...baza, AERE_PQGW_MODE: 'hybrid-preferred', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` }),
pornesteGw('gateway catre upstream oprit', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${await portLiber()}` }),
pornesteGw('gateway catre upstream https (cu CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${TS.port}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT }),
pornesteGw('gateway catre upstream https (fara CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portUpHttps}` }),
pornesteGw('gateway catre upstream tacut', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portTacut}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT, AERE_PQGW_CONNECT_TIMEOUT_MS: '1000' }),
]);
[TH, TP] = await Promise.all([robinet(GH.port), robinet(GP.port)]);
console.log(`pregatire: ${o.versiune}; Node ${process.version} + OpenSSL ${process.versions.openssl}; certificat ${AMPRENTA.slice(0, 23)}...`);
} catch (e) {
console.log(`STRICAT: pregatirea a cazut, nicio proba nu a rulat: ${e.message}`);
process.exitCode = 3;
curata();
process.exit(3);
}
// ---------------------------------------------------------------- pozitive
await test('(a) hybrid-only + client Node X25519MLKEM768: cererea trece, grupul de pe fir e X25519MLKEM768', async () => {
const inainte = TH.conexiuni.length;
const r = await cerere({ port: TH.port, cale: '/a?x=1', curbe: 'X25519MLKEM768' });
cere(r.status === 200, `status ${r.status}`);
cere(json(r).url === '/a?x=1', `upstream-ul a vazut ${json(r).url}`);
cere(r.protocol === 'TLSv1.3', `protocol ${r.protocol}`);
const rec = TH.conexiuni[inainte];
cere(rec, 'robinetul nu a vazut conexiunea');
const f = citesteFir(rec);
cere(f.ch.length >= 1 && f.ch[0].cote.includes(HIBRID), `ClientHello nu poarta o cota X25519MLKEM768 (cote: ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'})`);
const final = f.sh.filter((s) => !s.hrr).pop();
cere(final && final.grup === HIBRID, `ServerHello de pe fir: ${descrieSH(f.sh)}`);
const ekiText = JSON.stringify(r.eki);
cere(!r.eki || !r.eki.name || /MLKEM/i.test(r.eki.name), `getEphemeralKeyInfo pretinde un grup clasic: ${ekiText}`);
return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${ekiText}${r.eki && r.eki.name ? '' : ' (Node nu numeste grupul hibrid)'}`;
});
await test('(b) openssl s_client -groups X25519MLKEM768 (client independent): strangere reusita, grupul negociat X25519MLKEM768', async () => {
const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519MLKEM768', '-servername', 'localhost', '-CAfile', CALE_CERT,
'-verify_return_error', '-ign_eof'], 'GET /b-openssl HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim();
cere(r.cod === 0, `s_client a iesit cu ${r.cod}: ${r.e.slice(0, 200)}`);
cere(rand.includes('X25519MLKEM768'), `randul grupului: '${rand || 'lipseste'}'`);
cere(/Verify return code: 0 \(ok\)/.test(r.o), 'certificatul nu a fost verificat de s_client');
cere(/HTTP\/1\.1 200/.test(r.o) && r.o.includes('"url":"/b-openssl"'), 'raspunsul HTTP prin s_client lipseste sau nu vine de la upstream');
return rand;
});
await test('(c) POST de 1 MiB ajunge intreg la upstream (sha256 egal)', async () => {
const corp = crypto.randomBytes(1024 * 1024);
const r = await cerere({ port: GH.port, cale: '/c', metoda: 'POST', corp, anteturi: { 'content-type': 'application/octet-stream', 'content-length': corp.length } });
cere(r.status === 200, `status ${r.status}`);
const j = json(r);
const asteptat = crypto.createHash('sha256').update(corp).digest('hex');
cere(j.bodyBytes === corp.length, `upstream-ul a primit ${j.bodyBytes} octeti din ${corp.length}`);
cere(j.bodySha256 === asteptat, 'sha256 diferit la upstream');
return `${j.bodyBytes} octeti, sha256 ${asteptat.slice(0, 16)}...`;
});
await test('(k) corpul curge catre upstream fara bufferare intreaga (chunked, cererea ramane deschisa)', async () => {
const id = `curge-${crypto.randomUUID()}`;
const primul = new Promise((r) => evUp.once(`primul:${id}`, () => r(true)));
const p1 = crypto.randomBytes(64 * 1024), p2 = crypto.randomBytes(64 * 1024);
let aVazut = null;
const r = await new Promise((resolve, reject) => {
const q = https.request({ host: GAZDA, port: GH.port, path: '/curge', method: 'POST', headers: { 'x-proba-id': id, 'content-type': 'application/octet-stream' },
ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => {
const b = [];
res.on('data', (x) => b.push(x));
res.on('end', () => resolve({ status: res.statusCode, corp: Buffer.concat(b) }));
});
q.setTimeout(15000, () => q.destroy(new Error('fara raspuns in 15 s')));
q.on('error', reject);
q.write(p1);
Promise.race([primul, dormi(5000).then(() => false)]).then((v) => { aVazut = v; q.end(p2); });
});
cere(aVazut === true, 'upstream-ul nu a primit NIMIC din corp cat timp clientul tinea cererea deschisa (5 s): corpul e bufferat');
cere(r.status === 200, `status ${r.status}`);
const j = json(r);
cere(j.bodySha256 === crypto.createHash('sha256').update(Buffer.concat([p1, p2])).digest('hex') && j.bodyBytes === p1.length + p2.length, `corp diferit la upstream (${j.bodyBytes} octeti)`);
cere(!j.headers['transfer-encoding'] || j.headers['transfer-encoding'] === 'chunked', `transfer-encoding la upstream: ${j.headers['transfer-encoding']}`);
return `primii octeti la upstream inainte de sfarsitul cererii; ${j.bodyBytes} octeti intregi`;
});
// Un tunel WebSocket prin gateway-ul de pe `port`: 101, salutul upstream-ului, apoi ecoul unei incarcaturi aleatoare.
async function verificaTunel(port, cale) {
const cheie = crypto.randomBytes(16).toString('base64');
const asteptatAccept = crypto.createHash('sha1').update(cheie + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
const incarcatura = crypto.randomBytes(32 * 1024);
const salut = 'SALUT-DE-LA-UPSTREAM\n';
const rez = await new Promise((resolve, reject) => {
const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => {
s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});
let acc = Buffer.alloc(0), antet = null, trimis = false;
const t = setTimeout(() => { s.destroy(); reject(new Error(`tunelul nu a intors tot in 8 s (antet: ${antet ? antet.split('\r\n')[0] : 'niciunul'}, octeti dupa antet: ${antet ? acc.length : 0})`)); }, 8000);
s.on('data', (b) => {
acc = Buffer.concat([acc, b]);
if (antet === null) {
const k = acc.indexOf('\r\n\r\n');
if (k === -1) return;
antet = acc.subarray(0, k).toString('latin1');
acc = acc.subarray(k + 4);
}
if (!trimis && acc.length >= salut.length) { trimis = true; s.write(incarcatura); }
if (acc.length >= salut.length + incarcatura.length) { clearTimeout(t); s.end(); resolve({ antet, acc }); }
});
s.on('error', (e) => { clearTimeout(t); reject(e); });
});
cere(/^HTTP\/1\.1 101/.test(rez.antet), `raspuns: ${rez.antet.split('\r\n')[0]}`);
cere(rez.antet.toLowerCase().includes(`sec-websocket-accept: ${asteptatAccept.toLowerCase()}`), 'Sec-WebSocket-Accept lipsa sau gresit');
cere(rez.acc.subarray(0, salut.length).toString() === salut, 'salutul upstream -> client nu a trecut');
cere(rez.acc.subarray(salut.length, salut.length + incarcatura.length).equals(incarcatura), 'ecoul client -> upstream -> client difera');
const u = jurnalUpstream.filter((x) => x.upgrade && x.url === cale).pop();
cere(u && u.upgrade === 'websocket' && u.headers['x-aere-pq-gateway'] === 'hybrid-only' && u.headers['x-forwarded-proto'] === 'https', 'upstream-ul nu a vazut cererea de upgrade cu antetele gateway-ului');
return { octeti: incarcatura.length, u };
}
await test('(k2) raspuns inceput inainte de sfarsitul cererii si care curge mai mult decat REQUEST_TIMEOUT: nu e taiat', async () => {
const t0 = Date.now();
let msLaSfarsitCerere = null;
const r = await new Promise((resolve, reject) => {
const q = https.request({ host: GAZDA, port: GH.port, path: '/devreme', method: 'POST', headers: { 'content-type': 'application/octet-stream' },
ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => {
let corp = '';
res.setEncoding('utf8');
res.on('data', (x) => { corp += x; });
res.on('end', () => resolve({ status: res.statusCode, corp, complet: res.complete }));
res.on('close', () => { if (!res.complete) resolve({ status: res.statusCode, corp, complet: false }); });
res.on('error', () => {});
// cererea se incheie abia DUPA ce raspunsul a inceput
msLaSfarsitCerere = Date.now() - t0;
q.end('sfarsitul-cererii');
});
q.setTimeout(10000, () => q.destroy(new Error('fara raspuns complet in 10 s')));
q.on('error', reject);
q.write('inceputul-cererii');
});
const ms = Date.now() - t0;
cere(r.status === 200, `status ${r.status}`);
cere(r.complet && r.corp.endsWith('gata\n') && (r.corp.match(/bucata/g) || []).length === 6, `raspuns taiat la ${ms} ms (${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii): ${JSON.stringify(r.corp.slice(-30))}`);
cere(ms - msLaSfarsitCerere > 2000, `fixtura: raspunsul a curs doar ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii, sub REQUEST_TIMEOUT+marja, deci nu masoara nimic`);
return `raspuns complet, ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii (REQUEST_TIMEOUT 1500)`;
});
await test('(d) upgrade WebSocket: 101, salutul upstream-ului si ecoul trec prin tunel in ambele sensuri', async () => {
const r = await verificaTunel(GH.port, '/ws');
cere(r.u.tls === false, 'fixtura: upstream-ul acestui gateway trebuia sa fie http');
return `${r.octeti} octeti ecou identici, salut primit`;
});
await test('(d3) upgrade WebSocket catre upstream https (UPSTREAM_CA): tunelul merge, iar drumul catre upstream prefera hibridul', async () => {
const inainte = TS.conexiuni.length;
const r = await verificaTunel(GS.port, '/ws-tls');
cere(r.u.tls === true, 'upstream-ul nu a primit upgrade-ul peste TLS');
const rec = TS.conexiuni.slice(inainte).find((x) => citesteFir(x).sh.length > 0);
cere(rec, 'robinetul din fata upstream-ului https nu a vazut strangerea de mana a tunelului');
const f = citesteFir(rec);
const final = f.sh.filter((s) => !s.hrr).pop();
cere(final && final.grup === HIBRID, `drumul tunelului catre upstream: ${descrieSH(f.sh)}`);
return `${r.octeti} octeti ecou identici peste TLS; drum catre upstream: ${descrieSH(f.sh)}`;
});
await test('(d2) upstream-ul refuza upgrade-ul: raspunsul lui (403) ajunge la client cu Connection: close, apoi conexiunea se inchide', async () => {
const r = await upgradeBrut(GH.port, '/ws-refuz');
cere(/^HTTP\/1\.1 403/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`);
cere(/\r\nconnection: close(\r\n|$)/i.test(r.antet), `fara Connection: close in raspuns (${r.antet.replace(/\r\n/g, ' | ').slice(0, 200)})`);
cere(r.corp === 'nu-ws', `corp '${r.corp.slice(0, 40)}'`);
return '403 + corpul upstream-ului, conexiune inchisa';
});
await test('(e) antetele: X-Forwarded-* puse de gateway, x-aere-pq-gateway prezent, hop-by-hop si numele din Connection NU ajung', async () => {
const r = await cerere({ port: GH.port, cale: '/e', anteturi: {
Connection: 'keep-alive, X-Secret-Hop', 'X-Secret-Hop': 'nu-trebuie-sa-ajunga', 'Keep-Alive': 'timeout=5', 'Proxy-Authorization': 'Basic bnU=',
TE: 'trailers', 'X-Forwarded-For': '203.0.113.9', 'x-aere-pq-gateway': 'falsificat', 'X-End-To-End': 'da', Host: 'aplicatia.example',
} });
cere(r.status === 200, `status ${r.status}`);
const h = json(r).headers;
// controlul pozitiv al fixturii: un antet obisnuit TREBUIE sa treaca, altfel lipsa celorlalte nu inseamna nimic
cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic');
cere(h['x-forwarded-proto'] === 'https', `X-Forwarded-Proto: ${h['x-forwarded-proto']}`);
cere(h['x-aere-pq-gateway'] === 'hybrid-only', `x-aere-pq-gateway: ${h['x-aere-pq-gateway']}`);
cere(h['x-forwarded-host'] === 'aplicatia.example', `X-Forwarded-Host: ${h['x-forwarded-host']}`);
cere(h['x-forwarded-for'] === '127.0.0.1', `X-Forwarded-For: ${h['x-forwarded-for']} (valoarea clientului nu are voie sa treaca)`);
const scapate = ['x-secret-hop', 'keep-alive', 'proxy-authorization', 'te'].filter((n) => n in h);
cere(scapate.length === 0, `au ajuns la upstream: ${scapate.join(', ')}`);
cere(!String(h.connection || '').toLowerCase().includes('x-secret-hop'), `Connection la upstream: ${h.connection}`);
return 'x-secret-hop, keep-alive, proxy-authorization, te oprite; x-end-to-end trecut';
});
await test('(e2) X-Forwarded-For pe un ascultator dual-stack: ::ffff:a.b.c.d devine a.b.c.d, restul ramane neatins (functia, fara socket)', async () => {
// Masurat pe functie, nu pe un socket: un ascultator dual-stack ar insemna sa ascultam pe toate interfetele.
const mod = await import(pathToFileURL(GATEWAY).href);
const cazuri = [['::ffff:127.0.0.1', '127.0.0.1'], ['::FFFF:198.51.100.7', '198.51.100.7'], ['::1', '::1'], ['2001:db8::5', '2001:db8::5'], ['127.0.0.1', '127.0.0.1'], ['::ffff:nu-e-ip', '::ffff:nu-e-ip'], [undefined, '']];
const rele = cazuri.filter(([i, o]) => mod.adresaClient(i) !== o).map(([i, o]) => `${i} -> ${mod.adresaClient(i)} (asteptat ${o})`);
cere(rele.length === 0, rele.join('; '));
return `${cazuri.length} cazuri`;
});
await test('(f) punctul de stare: mode, contoare, amprenta certificatului, FARA cheia privata, servit de gateway (nu de upstream)', async () => {
const inainte = jurnalUpstream.length;
const r = await cerere({ port: GH.port, cale: CALE_STARE });
cere(r.status === 200, `status ${r.status}`);
cere(String(r.anteturi['content-type']).startsWith('application/json'), `content-type ${r.anteturi['content-type']}`);
const text = r.corp.toString('utf8');
const s = JSON.parse(text);
cere(s.mode === 'hybrid-only', `mode ${s.mode}`);
cere(JSON.stringify(s.groupsOffered) === '["X25519MLKEM768"]', `groupsOffered ${JSON.stringify(s.groupsOffered)}`);
cere(s.minTlsVersion === 'TLSv1.3', `minTlsVersion ${s.minTlsVersion}`);
cere(s.guarantee && s.guarantee.hybridKeyExchangeOnEveryConnection === true, 'hybrid-only nu isi declara garantia structurala');
cere(s.certificate && s.certificate.sha256 === AMPRENTA, `amprenta ${s.certificate && s.certificate.sha256} != ${AMPRENTA}`);
const k = s.counters || {};
for (const n of ['connectionsAccepted', 'requestsForwarded', 'responses502', 'responses504']) cere(Number.isInteger(k[n]), `contorul ${n} lipseste`);
cere(k.handshakesRefused && ['no shared group', 'unsupported protocol', 'other'].every((m) => Number.isInteger(k.handshakesRefused[m])), 'contoarele de refuz pe motiv lipsesc');
cere(k.connectionsAccepted >= 1 && k.requestsForwarded >= 1, 'contoarele nu numara nimic dupa probele de dinainte');
const baza64 = CHEIE_PEM.split(/\r?\n/).filter((l) => l && !l.startsWith('-----')).join('');
const fragmente = [baza64.slice(8, 48), baza64.slice(-40, -4), 'PRIVATE KEY', CALE_CHEIE, path.basename(CALE_CHEIE)];
const gasite = fragmente.filter((x) => x && text.includes(x));
cere(gasite.length === 0, `starea contine material sau cale a cheii (${gasite.length} fragmente)`);
cere(jurnalUpstream.slice(inainte).every((x) => !x.url.startsWith(CALE_STARE)), 'cererea de stare a fost trimisa la upstream');
return `acceptate ${k.connectionsAccepted}, trimise ${k.requestsForwarded}; ${fragmente.length} fragmente ale cheii cautate, 0 gasite`;
});
// ---------------------------------------------------------------- controale negative, fiecare cu MOTIVUL
await test('(g) hybrid-only + client numai X25519: refuzat la strangerea de mana, motivul numarat e "no shared group"', async () => {
const s0 = await stare(GH.port);
const inainte = TH.conexiuni.length;
const r = await strangere({ port: TH.port, curbe: 'X25519' });
const f = citesteFir(TH.conexiuni[inainte] || { c2s: [], s2c: [] });
// fixtura trebuie sa poata EXPRIMA atacul: clientul chiar nu a oferit hibridul
cere(f.ch.length === 1 && f.ch[0].grupuri.length === 1 && f.ch[0].grupuri[0] === X25519, `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}, nu numai X25519`);
cere(!r.ok, `strangerea de mana a REUSIT fara hibrid (${descrieSH(f.sh)}, getEphemeralKeyInfo ${JSON.stringify(r.eki)})`);
cere(f.sh.length === 0, `serverul a trimis ${descrieSH(f.sh)}`);
cere(r.cod === 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE', `clientul a primit alt refuz: ${r.cod}`);
const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']);
const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m];
cere(d('no shared group') === 1, `contorul 'no shared group' a crescut cu ${d('no shared group')} (unsupported protocol +${d('unsupported protocol')}, other +${d('other')})`);
cere(d('unsupported protocol') === 0 && d('other') === 0, `au crescut si alte motive: unsupported protocol +${d('unsupported protocol')}, other +${d('other')}`);
return `client: ${r.cod}; server: 'no shared group' +1 (${JSON.stringify(s1.counters.handshakesRefusedByCode)})`;
});
await test('(g2) hybrid-only + openssl s_client -groups X25519 (client independent): refuzat cu alerta handshake failure', async () => {
const s0 = await stare(GH.port);
const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519', '-servername', 'localhost', '-CAfile', CALE_CERT], '');
const tot = r.o + r.e;
// Masurat 2026-09-25: la refuz s_client tipareste totusi randul grupului, cu '<NULL>', si 'Cipher is (NONE)'.
const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim();
cere(r.cod !== 0, 's_client a iesit cu 0: strangerea de mana a reusit fara hibrid');
cere(/alert handshake failure/i.test(tot) && /SSL alert number 40/.test(tot), `s_client nu a raportat alerta handshake failure (40): ${tot.replace(/\s+/g, ' ').slice(0, 200)}`);
cere(rand === '' || /<NULL>$/.test(rand), `s_client raporteaza un grup negociat: '${rand}'`);
cere(/Cipher is \(NONE\)/.test(r.o), 's_client raporteaza o suita negociata');
const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']);
cere(s1.counters.handshakesRefused['no shared group'] === s0.counters.handshakesRefused['no shared group'] + 1, "contorul 'no shared group' nu a crescut cu 1");
return (tot.match(/[^\n]*alert handshake failure[^\n]*/i) || [''])[0].trim().slice(0, 120);
});
await test('(h) client TLS 1.2 (maxVersion TLSv1.2): refuzat in AMBELE moduri, motivul numarat e "unsupported protocol"', async () => {
// Amandoua gateway-urile se masoara inainte de verdict, ca un esec pe primul sa nu ascunda ce spune al doilea.
const note = [], probleme = [];
for (const [g, t] of [[GH, TH], [GP, TP]]) {
try {
const s0 = await stare(g.port);
const inainte = t.conexiuni.length;
const r = await strangere({ port: t.port, maxVersion: 'TLSv1.2' });
const f = citesteFir(t.conexiuni[inainte] || { c2s: [], s2c: [] });
cere(f.ch.length === 1 && !f.ch[0].versiuni.includes(0x0304), `clientul de proba a oferit TLS 1.3 (${f.ch[0] ? f.ch[0].versiuni.map((v) => v.toString(16)) : '-'})`);
cere(!r.ok, `un client TLS 1.2 a fost ACCEPTAT (${r.protocol})`);
const s1 = await asteaptaStare(g.port, (s) => s.counters.handshakesRefused['unsupported protocol'] > s0.counters.handshakesRefused['unsupported protocol'], 2000);
const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m];
cere(r.cod === 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', `clientul a primit alt refuz: ${r.cod} (server: 'no shared cipher' +${d('no shared cipher')}, other +${d('other')})`);
cere(d('unsupported protocol') === 1, `'unsupported protocol' +${d('unsupported protocol')}, 'no shared cipher' +${d('no shared cipher')}, other +${d('other')}`);
note.push(`${s1.mode}: ${r.cod}, 'unsupported protocol' +1`);
} catch (e) { probleme.push(`${g.nume}: ${e.message}`); }
}
cere(probleme.length === 0, probleme.join(' | '));
return note.join('; ');
});
await test('(i) upstream oprit: 502 cu corp JSON, in cateva secunde, numarat', async () => {
const s0 = await stare(GM.port);
const t0 = Date.now();
const r = await cerere({ port: GM.port, cale: '/i', timeoutMs: 9000 });
const ms = Date.now() - t0;
cere(r.status === 502, `status ${r.status}`);
const j = json(r);
cere(j.error === 'bad_gateway', `corp ${JSON.stringify(j)}`);
cere(ms < 7000, `a durat ${ms} ms`);
const s1 = await stare(GM.port);
cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`);
return `502 in ${ms} ms: ${JSON.stringify(j)}`;
});
await test('(i2) upstream care nu raspunde la timp: 504 cu corp JSON dupa REQUEST_TIMEOUT, numarat', async () => {
const s0 = await stare(GH.port);
const t0 = Date.now();
const r = await cerere({ port: GH.port, cale: '/slow?ms=6000', timeoutMs: 9000 });
const ms = Date.now() - t0;
cere(r.status === 504, `status ${r.status} dupa ${ms} ms`);
const j = json(r);
cere(j.error === 'gateway_timeout', `corp ${JSON.stringify(j)}`);
cere(ms >= 1400 && ms < 4500, `a durat ${ms} ms (timeout configurat 1500)`);
const s1 = await stare(GH.port);
cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`);
return `504 in ${ms} ms`;
});
await test('(i3) upgrade catre upstream oprit: 502 cu corp JSON pe socket, conexiune inchisa, numarat', async () => {
const s0 = await stare(GM.port);
const r = await upgradeBrut(GM.port, '/ws');
cere(/^HTTP\/1\.1 502/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`);
let j;
try { j = JSON.parse(r.corp); } catch { throw new Error(`corpul nu e JSON: '${r.corp.slice(0, 80)}'`); }
cere(j.error === 'bad_gateway', `corp ${r.corp.trim()}`);
const s1 = await stare(GM.port);
cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`);
return r.corp.trim();
});
await test('(s) clientul pleaca inainte de raspuns (cerere si upgrade): nu se numara ca 502/504 si nu apare ca eroare de upstream', async () => {
const s0 = await stare(GP.port);
const jurnal0 = GP.jurnal.length;
const plecat = await new Promise((resolve) => {
const q = https.request({ host: GAZDA, port: GP.port, path: '/slow?ms=2500', ca: CERT_PEM, servername: 'localhost', agent: false });
q.on('error', () => {});
q.end();
setTimeout(() => { q.destroy(); resolve(true); }, 400);
});
const sus = await new Promise((resolve) => {
const s = tls.connect({ host: GAZDA, port: GP.port, ca: CERT_PEM, servername: 'localhost' }, () => {
s.write('GET /ws-lent HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGVzdGVzdGVzdGVzdGVzdA==\r\nSec-WebSocket-Version: 13\r\n\r\n');
setTimeout(() => { s.destroy(); resolve(true); }, 400);
});
s.on('error', () => {});
});
cere(plecat && sus, 'fixtura nu a putut pleca');
await dormi(700);
const s1 = await stare(GP.port);
const d502 = s1.counters.responses502 - s0.counters.responses502, d504 = s1.counters.responses504 - s0.counters.responses504;
const erori = GP.jurnal.slice(jurnal0).filter((j) => j.event === 'upstream_error');
cere(s1.counters.requestsForwarded >= s0.counters.requestsForwarded + 2, `fixtura: cererile nu au ajuns la upstream (trimise +${s1.counters.requestsForwarded - s0.counters.requestsForwarded})`);
cere(d502 === 0 && d504 === 0, `plecarea clientului numarata ca eroare de upstream: responses502 +${d502}, responses504 +${d504}`);
cere(erori.length === 0, `jurnalul gateway-ului scrie upstream_error pentru o plecare a clientului: ${JSON.stringify(erori.map((j) => j.code))}`);
return 'responses502 +0, responses504 +0, niciun upstream_error';
});
await test('(q) upstream https cu AERE_PQGW_UPSTREAM_CA: cererea trece, iar drumul gateway -> upstream prefera hibridul (citit de pe fir)', async () => {
const inainte = TS.conexiuni.length;
const r = await cerere({ port: GS.port, cale: '/q' });
cere(r.status === 200 && json(r).url === '/q', `status ${r.status}`);
cere(json(r).headers['x-aere-pq-gateway'] === 'hybrid-only', 'upstream-ul https nu a vazut antetul gateway-ului');
const rec = TS.conexiuni[inainte];
cere(rec, 'robinetul din fata upstream-ului https nu a vazut conexiunea');
const f = citesteFir(rec);
const final = f.sh.filter((s) => !s.hrr).pop();
cere(final && final.grup === HIBRID, `drumul catre upstream: ${descrieSH(f.sh)}`);
const s = await stare(GS.port);
cere(s.upstream && s.upstream.scheme === 'https' && s.upstream.encrypted === true, `starea: ${JSON.stringify(s.upstream)}`);
cere(JSON.stringify(Object.keys(s.upstream)) === '["scheme","encrypted"]', `starea spune despre upstream mai mult decat schema: ${JSON.stringify(s.upstream)}`);
return `drum catre upstream: ${descrieSH(f.sh)}`;
});
await test('(q2) upstream https cu certificat pe care gateway-ul nu il poate verifica (fara UPSTREAM_CA): 502, nicio incredere oarba', async () => {
const inainte = jurnalUpstream.length;
const r = await cerere({ port: GSX.port, cale: '/q2' });
cere(r.status === 502, `status ${r.status}: gateway-ul a trimis cererea unui upstream neverificat`);
cere(json(r).error === 'bad_gateway', `corp ${r.corp.toString().trim()}`);
cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/q2'), 'cererea a ajuns la upstream');
const t0 = Date.now();
let rand;
while (!(rand = GSX.jurnal.filter((j) => j.event === 'upstream_error').pop()) && Date.now() - t0 < 2000) await dormi(50);
cere(rand && /CERT|SELF_SIGNED|VERIFY/i.test(String(rand.code)), `motivul din jurnalul gateway-ului: ${rand ? rand.code : 'lipseste'}`);
return `502, motiv in jurnal: ${rand.code}`;
});
await test('(r) upstream care accepta TCP dar nu termina strangerea TLS: 504 "upstream connect timeout" dupa CONNECT_TIMEOUT, numarat', async () => {
const s0 = await stare(GT.port);
const t0 = Date.now();
const r = await cerere({ port: GT.port, cale: '/r', timeoutMs: 9000 });
const ms = Date.now() - t0;
cere(r.status === 504, `status ${r.status} dupa ${ms} ms`);
const j = json(r);
cere(j.detail === 'upstream connect timeout', `corp ${JSON.stringify(j)}`);
cere(ms >= 900 && ms < 3500, `a durat ${ms} ms (timeout de conectare configurat 1000)`);
const s1 = await stare(GT.port);
cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`);
return `504 in ${ms} ms: ${JSON.stringify(j)}`;
});
await test('(j) hybrid-preferred + client numai X25519: ACCEPTAT pe X25519, iar starea NU pretinde hibrid', async () => {
const s0 = await stare(GP.port);
const inainte = TP.conexiuni.length;
const r = await cerere({ port: TP.port, cale: '/j', curbe: 'X25519' });
cere(r.status === 200, `status ${r.status}`);
const f = citesteFir(TP.conexiuni[inainte]);
cere(f.ch[0] && f.ch[0].grupuri.join() === String(X25519), `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`);
const final = f.sh.filter((s) => !s.hrr).pop();
cere(final && final.grup === X25519, `fir: ${descrieSH(f.sh)}`);
cere(r.eki && r.eki.name === 'X25519', `getEphemeralKeyInfo ${JSON.stringify(r.eki)}`);
const s1 = await stare(GP.port);
cere(s1.mode === 'hybrid-preferred', `mode ${s1.mode}`);
cere(s1.guarantee && s1.guarantee.hybridKeyExchangeOnEveryConnection === false, 'hybrid-preferred isi declara hibridul garantat pe fiecare conexiune');
cere(typeof s1.negotiatedGroupPerConnection === 'string' && /not reported/i.test(s1.negotiatedGroupPerConnection), 'starea nu spune ca grupul per conexiune nu e raportat');
const pretentii = [];
(function umbla(o, cale) {
for (const [k, v] of Object.entries(o || {})) {
if (v && typeof v === 'object') umbla(v, `${cale}.${k}`);
else if (/hybrid|mlkem|quantum|pq/i.test(k) && (v === true || (typeof v === 'number' && v > 0))) pretentii.push(`${cale}.${k}=${v}`);
}
})(s1, '');
cere(pretentii.length === 0, `starea pretinde hibrid: ${pretentii.join(', ')}`);
cere(s1.counters.connectionsAccepted > s0.counters.connectionsAccepted, 'conexiunea clasica nu a fost numarata ca acceptata');
return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${JSON.stringify(r.eki)}; nicio pretentie de hibrid in stare`;
});
await test('(j2) hybrid-preferred prefera CU ADEVARAT: client "X25519:X25519MLKEM768" (cota numai X25519) ajunge pe hibrid prin HelloRetryRequest', async () => {
const inainte = TP.conexiuni.length;
const r = await cerere({ port: TP.port, cale: '/j2', curbe: 'X25519:X25519MLKEM768' });
cere(r.status === 200, `status ${r.status}`);
const f = citesteFir(TP.conexiuni[inainte]);
cere(f.ch[0] && f.ch[0].cote.join() === String(X25519) && f.ch[0].grupuri.includes(HIBRID), `fixtura: primul ClientHello are cotele ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'} si grupurile ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`);
const final = f.sh.filter((s) => !s.hrr).pop();
cere(final && final.grup === HIBRID, `fir: ${descrieSH(f.sh)} (serverul nu a cerut hibridul)`);
return `fir: ${descrieSH(f.sh)}`;
});
await test('(m) antet peste limita: 431, nimic trimis la upstream', async () => {
const inainte = jurnalUpstream.length;
let r;
try { r = await cerere({ port: GH.port, cale: '/m-mare', anteturi: { 'x-mare': 'a'.repeat(20000) } }); } catch (e) { throw new Error(`fara raspuns HTTP: ${e.message}`); }
cere(r.status === 431, `status ${r.status}`);
cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/m-mare'), 'cererea a ajuns la upstream');
return '431';
});
await test('(n) ALPN: un client care ofera h2 primeste http/1.1 (gateway-ul nu face HTTP/2)', async () => {
const r = await strangere({ port: GH.port, curbe: 'X25519MLKEM768', alpn: ['h2', 'http/1.1'] });
cere(r.ok, `strangere esuata: ${r.cod}`);
cere(r.alpn === 'http/1.1', `ALPN ${r.alpn}`);
return `ALPN ${r.alpn}`;
});
await test('(o) configuratie din fisier JSON (AERE_PQGW_CONFIG), iar mediul are prioritate', async () => {
const fis = path.join(TMP, 'config.json');
fs.writeFileSync(fis, JSON.stringify({ listen: `${GAZDA}:0`, cert: CALE_CERT, key: CALE_CHEIE, upstream: `http://${GAZDA}:${PORT_UP}`, mode: 'hybrid-preferred', connectTimeoutMs: 3000 }));
const g = await pornesteGw('gateway din fisier', { AERE_PQGW_CONFIG: fis, AERE_PQGW_MODE: 'hybrid-only' });
try {
const s = await stare(g.port);
cere(s.mode === 'hybrid-only', `mode ${s.mode} (mediul trebuia sa castige fata de fisier)`);
const r = await cerere({ port: g.port, cale: '/o' });
cere(r.status === 200 && json(r).url === '/o', `cererea prin gateway-ul din fisier: ${r.status}`);
} finally { g.p.kill(); }
// exemplul publicat trebuie sa fie o configuratie pe care gateway-ul chiar o accepta
const mod = await import(pathToFileURL(GATEWAY).href);
const ex = mod.citesteConfig({ AERE_PQGW_CONFIG: path.join(AICI, 'pq-gateway.example.json') });
cere(ex.mode === 'hybrid-preferred' && ex.port === 8443, `exemplul citit: ${JSON.stringify({ mode: ex.mode, port: ex.port })}`);
return 'fisier citit, MODE din mediu aplicat; pq-gateway.example.json acceptat';
});
await test('(p) configuratie gresita: refuza sa porneasca, cu motivul numit, si nu asculta', async () => {
const altaCheie = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' });
const caleAlta = path.join(TMP, 'alta-cheie.pem');
fs.writeFileSync(caleAlta, altaCheie);
const fisRau = path.join(TMP, 'config-rau.json');
fs.writeFileSync(fisRau, JSON.stringify({ upstrem: 'http://x.invalid' }));
const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` };
const cazuri = [
['fara MODE', { ...baza }, 2, /MODE is required/],
['MODE necunoscut', { ...baza, AERE_PQGW_MODE: 'classic' }, 2, /MODE must be/],
['cheie in fisier gresita', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_CONFIG: fisRau }, 2, /unknown key in config file: upstrem/],
['cheia nu se potriveste cu certificatul', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_KEY: caleAlta }, 1, /KEY does not match/],
];
const note = [];
for (const [nume, env, codAsteptat, motiv] of cazuri) {
const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(env), timeout: 10000, encoding: 'utf8' });
cere(r.status === codAsteptat, `${nume}: cod ${r.status}, asteptat ${codAsteptat}; stderr ${String(r.stderr).slice(0, 160)}`);
cere(motiv.test(r.stderr), `${nume}: motivul lipseste din stderr: ${String(r.stderr).slice(0, 160)}`);
cere(!/"event":"listening"/.test(r.stdout), `${nume}: a ascultat totusi`);
cere(!String(r.stderr).includes(caleAlta) && !String(r.stderr).includes(CALE_CHEIE), `${nume}: calea cheii apare in mesaj`);
note.push(`${nume}: ${r.status}`);
}
return note.join('; ');
});
await test('(l) oprire curata: cererea in curs se termina, conexiunile noi sunt refuzate, oprirea se incheie singura', async () => {
const mod = await import(pathToFileURL(GATEWAY).href);
const cfg = mod.citesteConfig({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_MODE: 'hybrid-only' });
const gw = await mod.pornesteGateway(cfg, { jurnal: () => {} });
const port = gw.adresa.port;
const inCurs = cerere({ port, cale: '/slow?ms=1200', timeoutMs: 9000 }).then((r) => r, (e) => ({ eroare: e }));
await dormi(400);
const t0 = Date.now();
const oprit = gw.opreste(5000).then(() => Date.now() - t0);
await dormi(100);
const nou = await strangere({ port, curbe: 'X25519MLKEM768' });
const r = await inCurs;
const msOprire = await Promise.race([oprit, dormi(8000).then(() => null)]);
cere(!r.eroare, `cererea in curs a fost taiata: ${r.eroare && r.eroare.message}`);
cere(r.status === 200 && json(r).url === '/slow?ms=1200', `cererea in curs: status ${r.status}`);
cere(String(r.anteturi.connection).toLowerCase() === 'close', `raspunsul din timpul opririi are Connection: ${r.anteturi.connection}`);
cere(!nou.ok && nou.cod === 'ECONNREFUSED', `o conexiune noua dupa oprire: ${nou.ok ? 'ACCEPTATA' : nou.cod}`);
cere(msOprire !== null && msOprire < 4000, `oprirea nu s-a incheiat (${msOprire} ms)`);
return `cererea in curs 200, conexiune noua ${nou.cod}, oprire in ${msOprire} ms`;
});
// ---------------------------------------------------------------- autentificarea post-cuantica (2026-09-25)
function pornireRefuzata(extra) {
const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(extra), encoding: 'utf8', timeout: 15000 });
return { cod: r.status, stderr: r.stderr || '', stdout: r.stdout || '' };
}
await test('(s) lant ML-DSA emis de aere-pq-pki + REQUIRE_PQ_AUTH: porneste, starea spune post-quantum, openssl vede mldsa65 + X25519MLKEM768 si verifica lantul', async () => {
// AERE_PQ_PKI: controlul negativ ruleaza proba dintr-o copie a dosarului, deci calea autoritatii vine din mediu
const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href);
const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Root' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 30 });
const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Issuing' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 30 });
const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] });
const cRoot = path.join(TMP, 'pq-root.pem'), cLant = path.join(TMP, 'pq-lant.pem'), cCheie = path.join(TMP, 'pq-cheie.pem');
fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root));
fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf) + P.pem('CERTIFICATE', inter));
fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
const g = await pornesteGw('gateway cu lant ML-DSA', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only',
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
const radacina = fs.readFileSync(cRoot);
const s = await stare(g.port, radacina);
cere(s.certificate.authentication === 'post-quantum', `autentificarea din stare: ${s.certificate.authentication}`);
cere(s.certificate.chain.length === 2 && s.certificate.chain.every((c) => c.publicKeyAlgorithm === 'ML-DSA-65'), `lantul din stare: ${JSON.stringify(s.certificate.chain)}`);
cere(s.certificate.chain[1].signatureAlgorithm === 'ML-DSA-87', `semnatura radacinii pe intermediar: ${s.certificate.chain[1].signatureAlgorithm}`);
const o = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-verify_hostname', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n');
const t = o.o + o.e;
cere(/Verify return code: 0 \(ok\)/.test(t), `openssl verificarea: ${t.slice(0, 300)}`);
cere(/Peer signature type: mldsa65/.test(t), 'semnatura serverului nu e mldsa65');
cere(/Negotiated TLS1.3 group: X25519MLKEM768/.test(t), 'grupul nu e X25519MLKEM768');
const r = await cerere({ port: g.port, cale: '/ecou', ca: radacina });
cere(r.status === 200, `cererea prin gateway: ${r.status}`);
return 'post-quantum, mldsa65 + X25519MLKEM768, lantul verificat de openssl';
});
await test('(s2) REQUIRE_PQ_AUTH cu certificatul clasic (EC P-256): refuza sa porneasca, motivul numeste lantul classical; fara optiune starea spune classical', async () => {
const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only',
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: 'true' });
cere(r.cod === 1, `cod ${r.cod}`);
cere(/REQUIRE_PQ_AUTH is set but the served chain is classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 200)}`);
cere(!/"event":"listening"/.test(r.stdout), 'a ascultat totusi');
const s = await stare(GH.port);
cere(s.certificate.authentication === 'classical', `starea gateway-ului clasic: ${s.certificate.authentication}`);
return 'refuzat la pornire; starea clasicului: classical';
});
await test('(s3) lant MIXT (frunza ML-DSA semnata de o autoritate EC, facut de openssl): REQUIRE_PQ_AUTH refuza, cu "mixed" si semnatura numita', async () => {
const k = (n) => path.join(TMP, n);
execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', k('ecca.key'), '-out', k('ecca.pem'), '-days', '2',
'-subj', '/CN=EC CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], { stdio: ['ignore', 'pipe', 'pipe'] });
execFileSync(OPENSSL, ['req', '-new', '-newkey', 'mldsa65', '-nodes', '-keyout', k('mix.key'), '-out', k('mix.csr'), '-subj', '/CN=localhost'], { stdio: ['ignore', 'pipe', 'pipe'] });
execFileSync(OPENSSL, ['x509', '-req', '-in', k('mix.csr'), '-CA', k('ecca.pem'), '-CAkey', k('ecca.key'), '-out', k('mix.pem'), '-days', '2', '-set_serial', '9'], { stdio: ['ignore', 'pipe', 'pipe'] });
const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: k('mix.pem'), AERE_PQGW_KEY: k('mix.key'), AERE_PQGW_MODE: 'hybrid-only',
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
cere(r.cod === 1, `cod ${r.cod}; ${r.stderr.slice(0, 200)}`);
cere(/the served chain is mixed/.test(r.stderr) && /ML-DSA-65 key signed with classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 260)}`);
return 'mixed: cheie ML-DSA-65, semnatura clasica';
});
// ---------------------------------------------------------------- revizuirea adversariala din 2026-09-25 (A8, A9)
await test('(e3) antetele de identitate: X-Real-IP e al gateway-ului (nu al clientului), X-Client-* si X-SSL-* ale clientului NU ajung', async () => {
const r = await cerere({ port: GH.port, cale: '/e3', anteturi: {
'X-Real-IP': '1.2.3.4', 'X-Client-Cert': 'fals', 'X-SSL-Client-S-DN': 'CN=fals', 'X-Client-Verify': 'SUCCESS', 'X-End-To-End': 'da',
} });
cere(r.status === 200, `status ${r.status}`);
const h = json(r).headers;
cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic');
cere(h['x-real-ip'] === '127.0.0.1', `X-Real-IP la upstream: ${h['x-real-ip']} (trebuia adresa din socket, nu a clientului)`);
const scapate = ['x-client-cert', 'x-ssl-client-s-dn', 'x-client-verify'].filter((n) => n in h);
cere(scapate.length === 0, `antete de identitate ale clientului ajunse la upstream: ${scapate.join(', ')}`);
return 'X-Real-IP din socket; x-client-cert, x-ssl-client-s-dn, x-client-verify oprite';
});
await test('(g3) lant ML-DSA + client fara algoritm de semnatura PQ (numai ECDSA/RSA-PSS, ca browserele de azi): refuzat si numarat "no shared signature algorithm"', async () => {
const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href);
const kr = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'G3 Root' }, publicKey: kr.publicKey, ca: true, pathLen: 0, days: 5 });
const leaf = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], eku: ['serverAuth'] });
const cLant = path.join(TMP, 'g3-lant.pem'), cCheie = path.join(TMP, 'g3-cheie.pem'), cRoot = path.join(TMP, 'g3-root.pem');
fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf)); fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root));
fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
const g = await pornesteGw('gateway ML-DSA pentru (g3)', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only',
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
const radacina = fs.readFileSync(cRoot);
// controlul pozitiv: cu algoritmii impliciti (care includ mldsa65) strangerea reuseste
// s_client tipareste "Verify return code: 0 (ok)" SI cand strangerea a cazut inainte de orice verificare (masurat 2026-09-25:
// alerta 40, 7 octeti cititi, si tot "0 (ok)"); succesul se citeste din "Peer signature type", refuzul din alerta.
const bun = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n');
cere(/Peer signature type: mldsa65/.test(bun.o + bun.e) && /Verify return code: 0 \(ok\)/.test(bun.o + bun.e), 'controlul pozitiv (client cu mldsa65) nu a reusit');
const rau = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-groups', 'X25519MLKEM768', '-sigalgs', 'ECDSA+SHA256:RSA-PSS+SHA256'], 'Q\n');
const tr = rau.o + rau.e;
cere(/alert handshake failure|SSL alert number 40/.test(tr) && !/Peer signature type:/.test(tr), `clientul fara algoritm PQ de semnatura nu a fost refuzat cu alerta: ${tr.split('\n').filter((l) => /alert|Peer signature|Negotiated/.test(l)).join(' | ').slice(0, 200)}`);
const s = await asteaptaStare(g.port, (x) => (x.counters.handshakesRefused['no shared signature algorithm'] || 0) >= 1, 4000, radacina);
cere(s && s.counters.handshakesRefused['no shared signature algorithm'] >= 1, `refuzul nu e numarat sub motivul lui: ${JSON.stringify(s && s.counters.handshakesRefused)}`);
return `numarat: no shared signature algorithm = ${s.counters.handshakesRefused['no shared signature algorithm']}`;
});
console.log(`PROBA PQ-GATEWAY: ${treceri} treceri, ${esecuri} esecuri${nemasurate ? `, ${nemasurate} nemasurate` : ''}`);
process.exitCode = esecuri ? 1 : 0;
suitaTerminata = true;
curata();

2
pq-kms/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# dosarul implicit de date (chei sigilate, jurnal de audit); nu intra niciodata in depozit
data/

284
pq-kms/README.md Normal file
View File

@ -0,0 +1,284 @@
# Aere PQ KMS
A small "transit"-style key management service (in the spirit of Vault's transit engine) where every
key is **hybrid classical + post-quantum**. It encrypts, decrypts, rewraps, issues data keys, signs and
verifies. Callers never see private keys unless a key was explicitly created as exportable.
It runs on **Node.js 24 only** and uses nothing but `node:crypto` (OpenSSL 3.5), `node:http`,
`node:fs`. No dependencies.
| key type | algorithms | what the hybrid means |
|-----------|------------------------------------|-----------------------|
| `encrypt` | X25519 + ML-KEM-768, AES-256-GCM | the data key is derived from **both** shared secrets; recovering it requires both |
| `sign` | Ed25519 + ML-DSA-65 | a signature carries **both** halves; verification fails if **either** half fails |
## What it is NOT
- **Not an HSM itself.** Private keys live on disk sealed under a root key, and in process memory while in use.
Anyone who can read process memory, or who has both the data directory and the root key, has the keys.
- **The root key comes from the environment** (`AERE_KMS_ROOT_KEY`) **or, since 2026-09-28, sealed by an HSM**
(`AERE_KMS_ROOT_HSM`, see below). With the environment there is no physical separation, no key ceremony, no
secret sharing, no unseal quorum; protect the environment accordingly. Only the derived key buffer is zeroed
after derivation; the hex string itself stays readable in the process (configuration) for its lifetime.
- **Not audited.** No third party has reviewed this code or the hybrid construction below.
- **Single process.** There is no file locking; do not point two processes at the same data directory. If
two processes do write the same audit log, the chain breaks and the next start **refuses** with
`AUDIT_CHAIN_INVALID` until the log is moved aside: it is a stop, not a degradation.
- **Audit verification is linear.** Startup and `GET /v1/audit/verify` re-read and re-authenticate the whole
log; on a very large log a caller holding the token can make that endpoint expensive. Rotate the log by
moving it aside (keep it as evidence) and starting a new chain.
- **No TLS.** The server speaks plain HTTP and listens on `127.0.0.1` by default. Put a TLS-terminating
proxy in front of it before exposing it to anything else.
- **One static bearer token.** There are no per-client identities, roles or per-key ACLs, and the audit
log does not record who made a request.
- No key deletion, no root-key rotation, no rate limiting, no replication or backup.
## Root key sealed by an HSM (PKCS#11)
`hsm-radacina.mjs` seals the 32-byte root under an AES-256 key generated **inside** a PKCS#11 token
(`CKA_SENSITIVE`, never extractable) and stores only the sealed form on disk. At start the server asks the
HSM to open it with the token PIN (`AERE_HSM_PIN`, read by `pkcs11-tool` from the environment, never on a
command line). Someone with the disk and the environment but without the HSM (and its PIN) does not have
the root. Set exactly one of `AERE_KMS_ROOT_KEY` / `AERE_KMS_ROOT_HSM`; both is refused (`ROOT_KEY_AMBIGUOUS`).
AERE_HSM_PIN=... node hsm-radacina.mjs sigileaza --modul /usr/lib/softhsm/libsofthsm2.so --token aere-kms --id 0a --iesire root-hsm.json --nou
AERE_KMS_ROOT_HSM=root-hsm.json AERE_HSM_MODULE=/usr/lib/softhsm/libsofthsm2.so AERE_HSM_PIN=... AERE_KMS_TOKEN=... node server.mjs
The sealed file does not choose what the process loads or sends: the PKCS#11 library comes from the process
configuration (`AERE_HSM_MODULE`, an absolute path) and must be exactly the one recorded in the file, checked
before any HSM tool runs (`HSM_MODULE_NOT_ALLOWED` otherwise); the PIN is always read from `AERE_HSM_PIN`, and a
file naming another variable is refused. So write access to the sealed file alone cannot make the KMS load
another library or hand it the PIN or another secret. A modified seal is refused with one code and one message
whichever check catches it (padding or the root's digest), so startup errors are not a padding oracle.
Measured on SoftHSM2 2.6.1 + OpenSC 0.25 (`test/proba-hsm.mjs`, 20/20 on 2026-09-29, needs Node 24 and a PKCS#11 module; the
library and PIN rules above in `test/proba-hsm-incredere.mjs`, 7/7 without an HSM, with `test/control-negativ-hsm-incredere.mjs`):
key generated in the token and not readable out of it, seal/open round trip, the KMS started from the
HSM-opened root and a hybrid round trip through it, and named refusals for a wrong PIN, a missing PIN,
another token, another key and a modified seal. What it does **not** change: after opening, the root is in
process memory as before; the hybrid ML-KEM/ML-DSA operations run in the KMS, not in the HSM (standard
PKCS#11 does not expose them); `pkcs11-tool` works on files, so the root passes for milliseconds through a
file under `/dev/shm`, zero-filled and removed. The mechanism is AES-CBC-PAD (OpenSC 0.25 does not expose
AES-GCM for encryption), so the seal carries 16 bytes of the root's own digest and a modified seal is a named
refusal, never a wrong root. Not yet exercised against a physical HSM.
## Threat model (short)
Defended, and exercised by the test suite (`test/proba.mjs`):
- **A future quantum adversary against stored ciphertexts** (harvest now, decrypt later): the data key
depends on the ML-KEM-768 secret, so breaking X25519 alone does not open an envelope. The reverse also
holds: a flaw in ML-KEM alone does not open an envelope while X25519 stands.
- **Signature forgery with one broken scheme:** a forger must produce valid Ed25519 **and** ML-DSA-65
signatures; the halves cannot be separated and reused as plain signatures over the raw message.
- **Tampering with ciphertexts:** any modified byte, a different `aad`, a different key, a different
version or a retired version is refused with a named reason.
- **Theft of the data directory without the root key:** private keys are sealed with AES-256-GCM; key
files carry an HMAC so that swapped public keys or a lowered `min_decryption_version` are refused.
- **Editing the audit log without the root key:** rows are HMAC-chained; a deleted, changed or reordered
row is detected.
Not defended:
- Compromise of the running process or of the host (memory, environment, debugger).
- An attacker with the root key.
- **Rollback** of the whole data directory (or of one key file) to an older, validly-MACed state, e.g. to
undo a raise of `min_decryption_version`. There is no monotonic counter.
- **Truncation of the audit log's tail** is only detected against an externally recorded head
(`verifyAudit({ expectedHead })`); on its own a hash chain cannot see missing trailing rows.
- Side channels (timing, cache, power) of the underlying OpenSSL implementations were not evaluated.
## Running
```sh
export AERE_KMS_ROOT_KEY=<64 hex characters> # required; the service refuses to start without it
export AERE_KMS_TOKEN=<at least 32 printable chars> # required; clients send "Authorization: Bearer <token>"
node server.mjs
```
| variable | default | meaning |
|---------------------|--------------------|---------|
| `AERE_KMS_ROOT_KEY` | none (required, or `AERE_KMS_ROOT_HSM`) | 32 bytes as hex. Missing, malformed or all-zero: refuses to start. **Never generated automatically.** |
| `AERE_KMS_ROOT_HSM` | none | path of a root sealed by an HSM (see above); exactly one of this and `AERE_KMS_ROOT_KEY` |
| `AERE_HSM_MODULE` | none (required with `AERE_KMS_ROOT_HSM`) | absolute path of the PKCS#11 library; must equal the one recorded in the sealed file |
| `AERE_HSM_PIN` | none (required with `AERE_KMS_ROOT_HSM`) | the token PIN, read by `pkcs11-tool` from the environment, never on a command line |
| `AERE_KMS_TOKEN` | none (required) | bearer token, 32+ printable ASCII characters, must differ from the root key |
| `AERE_KMS_HOST` | `127.0.0.1` | listen address |
| `AERE_KMS_PORT` | `8420` | listen port (`0` picks a free one) |
| `AERE_KMS_MAX_BODY` | `65536` | request body limit in bytes, 8192 to 16777216 (a hybrid signature alone is 4531 base64 characters) |
| `AERE_KMS_DATA_DIR` | `./data` next to `server.mjs` | key files, root check, audit log |
A data directory is bound to the root key it was created with: opening it with another root key fails
with `ROOT_KEY_MISMATCH`. No error message ever contains key material, the root key or the token.
Generate a root key with, for example:
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
## HTTP API
All bodies are JSON. Binary values (`plaintext`, `aad`, `message`) are **standard base64**; ciphertexts
and signatures are the self-describing strings below. Errors are `{ "error": CODE, "message": text }`.
Every route except `GET /v1/health` requires `Authorization: Bearer <token>`.
| method and path | body | response |
|---|---|---|
| `GET /v1/health` | | `{ ok: true }` (no auth) |
| `GET /v1/keys` | | `{ keys: [names] }` |
| `POST /v1/keys/:name` | `{ type: "encrypt"\|"sign", exportable?: false }` | key description (public keys only) |
| `GET /v1/keys/:name` | | key description |
| `POST /v1/keys/:name/rotate` | | key description with a new latest version |
| `POST /v1/keys/:name/config` | `{ min_decryption_version: n }` | key description |
| `GET /v1/keys/:name/export[/:version]` | | private keys (PKCS#8 DER, base64); `403 KEY_NOT_EXPORTABLE` unless exportable |
| `POST /v1/encrypt/:name` | `{ plaintext, aad? }` | `{ ciphertext, version }` |
| `POST /v1/decrypt/:name` | `{ ciphertext, aad? }` | `{ plaintext, version }` |
| `POST /v1/rewrap/:name` | `{ ciphertext, aad? }` | `{ ciphertext, version }` (the plaintext never leaves the process) |
| `POST /v1/datakey/:name` | `{ aad?, bits?: 128\|256\|512, include_plaintext?: true }` | `{ ciphertext, version, plaintext? }` |
| `POST /v1/sign/:name` | `{ message }` | `{ signature, version }` |
| `POST /v1/verify/:name` | `{ message, signature }` | `{ valid, reason, version, classical, post_quantum }` |
| `GET /v1/audit/verify` | | `{ ok, rows, head }` or `{ ok: false, reason, line }` |
Key names match `^[a-z0-9][a-z0-9_-]{0,63}$`.
### Versions, rotation and retirement
- `rotate` adds a version; `encrypt`, `datakey`, `rewrap` and `sign` always use the latest one.
- Older versions keep decrypting and verifying until `min_decryption_version` is raised past them.
After that, decrypt and rewrap fail with `VERSION_BELOW_MINIMUM`, and verify returns
`valid: false, reason: "VERSION_BELOW_MINIMUM"`.
- `min_decryption_version` **can only be raised** (`MIN_VERSION_NOT_MONOTONIC`), and never above the latest
version (`VERSION_OUT_OF_RANGE`). Retired private keys stay on disk; there is no trim.
- `exportable` is set at creation and cannot be changed. It defaults to `false`.
### Error codes
| code | when |
|---|---|
| `MALFORMED_CIPHERTEXT`, `MALFORMED_SIGNATURE` | not `aerekms:v<n>:<base64>`, non-canonical base64, wrong header, wrong length |
| `VERSION_MISMATCH` | the prefix version and the embedded version differ, or the envelope belongs to another version of this key |
| `KEY_MISMATCH` | the envelope was produced by a different key |
| `UNKNOWN_VERSION` | the key has no such version |
| `VERSION_BELOW_MINIMUM` | the version is retired by `min_decryption_version` |
| `HEADER_AUTH_FAILED` | the key encapsulation, header or aad tag was modified, or the envelope was not produced for this key version (key commitment failed) |
| `AAD_MISMATCH` | the `aad` differs from the one used at encryption |
| `PAYLOAD_AUTH_FAILED` | the encrypted payload or its GCM tag was modified or truncated |
| `CLASSICAL_SIGNATURE_INVALID`, `PQ_SIGNATURE_INVALID`, `BOTH_SIGNATURES_INVALID` | which half of a hybrid signature failed |
| `KEY_NOT_FOUND`, `KEY_EXISTS`, `WRONG_KEY_TYPE`, `INVALID_KEY_NAME`, `INVALID_KEY_TYPE`, `INVALID_POLICY`, `INVALID_BITS`, `KEY_NOT_EXPORTABLE` | request errors |
| `ROOT_KEY_MISSING`, `ROOT_KEY_INVALID`, `ROOT_KEY_WEAK`, `ROOT_KEY_MISMATCH`, `ROOT_CHECK_MISSING` | refuses to start |
| `KEY_FILE_TAMPERED`, `SEAL_AUTH_FAILED` | a key file on disk fails its integrity check |
| `AUDIT_CHAIN_INVALID` | refuses to start on an audit log that does not verify (move it aside as evidence to begin a new chain) |
| `AUDIT_WRITE_FAILED` | the audit row could not be written; the result is withheld, and any change the operation made to the key file (a new key, a new version, a raised minimum) is rolled back, so a change exists only if its audit row exists |
| `UNAUTHENTICATED`, `INVALID_TOKEN` | 401 |
| `BODY_TOO_LARGE` | 413 |
## Library API
```js
import { openKms } from './kms.mjs';
const kms = openKms({ dataDir: './data', rootKey: process.env.AERE_KMS_ROOT_KEY });
kms.createKey('orders', { type: 'encrypt' }); // exportable: false by default
const { ciphertext } = kms.encrypt('orders', 'secret', 'tenant-42');
const { plaintext } = kms.decrypt('orders', ciphertext, 'tenant-42');
kms.rotate('orders'); kms.rewrap('orders', ciphertext, 'tenant-42');
kms.setMinDecryptionVersion('orders', 2);
kms.datakey('orders', { aad: 'file-7', bits: 256, includePlaintext: true });
kms.createKey('releases', { type: 'sign' });
const { signature } = kms.sign('releases', 'artifact bytes');
kms.verify('releases', 'artifact bytes', signature); // { valid, reason, classical, post_quantum, version }
kms.verifyAudit({ expectedHead }); // expectedHead = an earlier kms.auditHead()
```
Refusals throw `KmsError` with a `code` from the table above (`verify` returns `valid: false` instead).
## Wire format
Everything below is fixed by this implementation so that third parties can interoperate using only
standard primitives; `test/proba.mjs` contains a second, independent implementation of it (including
a hand-written HKDF) that decapsulates the service's envelopes and builds envelopes the service accepts.
Notation: `u32` is big-endian, `lp(x)` is `u32(len(x)) || x`, strings are UTF-8, `\0` is a zero byte.
### Public keys and fingerprint
- `encrypt` version: `x25519` = raw 32-byte public key; `ml_kem_768` = SPKI DER (1206 bytes; the raw
1184-byte encapsulation key follows a fixed 22-byte header).
- `sign` version: `ed25519` = raw 32 bytes; `ml_dsa_65` = SPKI DER (1974 bytes; raw key 1952 bytes).
- `fingerprint = SHA-256("aerekms/v1/fingerprint\0" || kind || lp(name) || u32(version) || pubA_raw || pubB_raw)[0..8]`,
with `kind` = `0x45` ('E') or `0x53` ('S').
### Ciphertext: `aerekms:v<version>:<base64(body)>`
```
body = "AKM1" | 0x45 | u32 version | fp(8) | ePub(32) | ctK(1088) | aadTag(16) | commit(16) | payload | gcmTag(16)
```
1. `ePub` is a fresh X25519 public key; `ssX = X25519(eph, recipient_x25519)`.
2. `(ssK, ctK) = ML-KEM-768.Encaps(recipient_ml_kem_768)`.
3. `transcript = "aerekms/v1/hybrid-kem\0" || lp(name) || u32(version) || fp || recipient_x25519_raw || SHA-256(recipient_ek_raw) || ePub || ctK`
4. `IKM = ssK || ssX`, `salt = SHA-256(transcript)`, and with HKDF-SHA-256:
- `commitKey = HKDF(IKM, salt, "aerekms/v1/commit", 32)`
- `aadKey = HKDF(IKM, salt, "aerekms/v1/aad", 32)`, `aadTag = HMAC-SHA-256(aadKey, aad)[0..16]`
- `key || iv = HKDF(IKM, salt, "aerekms/v1/dek\0" || SHA-256(aad), 44)`
5. `commit = HMAC-SHA-256(commitKey, body[0 .. commit offset))[0..16]` (a key commitment over the whole
header, including `aadTag`).
6. `payload || gcmTag = AES-256-GCM(key, iv, plaintext, AAD = body[0 .. payload offset))`.
Decryption checks, in order: format, prefix version = embedded version, version exists and is not retired,
fingerprint, decapsulation, `commit`, `aadTag`, GCM. Each step has its own error code. Absent `aad` and
empty `aad` are the same thing.
The combiner is the common "concatenate both secrets, bind both encapsulations, then KDF" construction.
No formal security proof is provided here.
### Signature: `aerekms:v<version>:<base64(body)>`
```
body = "AKS1" | 0x53 | u32 version | fp(8) | ed25519_sig(64) | ml_dsa_65_sig(3309)
M' = "aerekms/v1/hybrid-sig\0ed25519+ml-dsa-65\0" || fp || u32(version) || message
```
Ed25519 signs `M'`; ML-DSA-65 (pure, FIPS 204) signs `M'` with context string `"aerekms/v1"`. Because both
sign `M'` and not the raw message, neither half is a valid signature over the message on its own.
### Storage
- `data/root-check.json`: an HMAC under a key derived from the root key; detects a wrong root key.
- `data/keys/<name>.json`: key metadata, public keys, and per version `private_sealed` =
`base64(iv(12) || AES-256-GCM ciphertext || tag(16))` of the PKCS#8 private keys, with
AAD `"aerekms/v1/seal\0" name "\0" type "\0" version "\0" fingerprint_hex`. The whole file carries
`mac` = HMAC-SHA-256 over its canonical JSON. Files are written atomically (temp file, fsync, rename).
- Sub-keys are derived from the root key with HKDF-SHA-256 (salt `"aerekms/v1/root"`, one info label each:
seal, key-file MAC, audit chain, root check). The derived root key buffer is zeroed after derivation; the
environment variable it came from is not (see the limits above).
### Audit log: `data/audit.log`
One JSON object per line: `{ seq, ts, op, key, version, ok, reason, prev, mac }`, where `prev` is the
previous row's `mac` (64 zeros for the first row) and `mac = HMAC-SHA-256(auditKey, canonical JSON of the
row without mac)`. Rows never contain plaintext, aad, messages, ciphertexts or key material. Every
operation writes a row, including refused ones (with `ok: false` and the reason code); the row is
fsynced before the result is returned. `verifyAudit()` reports `AUDIT_ROW_MODIFIED`,
`AUDIT_CHAIN_BROKEN` (deleted or reordered rows), `AUDIT_ROW_UNPARSABLE`, and, given an external head,
`AUDIT_TRUNCATED` / `AUDIT_HEAD_MISMATCH`. Record `auditHead()` somewhere else if tail truncation matters.
## Tests
```sh
node test/proba.mjs # the test suite
node test/control-negativ.mjs # plants defects in a copy; each must turn named tests red
node test/control-negativ.mjs --autoproba # decoy plants; the control itself must report each as a failure
```
`test/proba.mjs` pairs every positive assertion with a negative one and checks the refusal **reason**,
not just that something failed. `test/control-negativ.mjs` copies the code to a temp directory, plants
one defect at a time (KDF without the ML-KEM secret, KDF without the X25519 secret, verification that
accepts one signature, ignored aad, ignored minimum version, unchained audit writer, verifier that skips
the chain, verifier that skips row MACs, private key written in clear, key file MAC not checked, root
key generated silently, fingerprint not checked, rewrap returning plaintext, missing auth accepted, body
limit ignored), and requires the named tests to fail with the named message. It reads the suite's JSON
output, not its exit code, and checks that the original files are byte-identical afterwards.
Measured on 2026-09-25, Windows 11, Node 24.14.1, OpenSSL 3.5.5: 61/61 tests pass; the negative control
catches 15/15 plantings; the control's self-test gives 5/5 decoys their failure verdict. Not measured:
Linux or macOS (including whether the `0o600`/`0o700` file modes are applied; Windows ignores them),
throughput under load, behaviour with concurrent processes, side channels.

142
pq-kms/hsm-radacina.mjs Normal file
View File

@ -0,0 +1,142 @@
// hsm-radacina.mjs - Aere PQ KMS: cheia radacina SIGILATA de un HSM prin PKCS#11 (roadmap master 16, "HSM").
//
// DE CE: pana acum radacina KMS-ului statea IN CLAR in mediu (AERE_KMS_ROOT_KEY), iar README-ul o spune: "Not an HSM". Cu acest
// modul radacina sta pe disc numai SIGILATA de o cheie AES-256 care traieste in HSM si nu poate iesi de acolo (CKA_SENSITIVE,
// CKA_EXTRACTABLE=false); la pornire, KMS-ul cere HSM-ului sa o deschida, cu PIN-ul tokenului. Cine fura discul si mediul fara HSM
// si fara PIN nu are radacina. Masurat pe SoftHSM2 2.6.1 + OpenSC 0.25 (test/proba-hsm.mjs); un HSM fizic se foloseste la fel,
// prin modulul PKCS#11 al producatorului.
//
// CE NU FACE (spus, nu ascuns): dupa deschidere radacina e in memoria procesului, ca inainte (HSM-ul nu face operatiile hibride
// ML-KEM/ML-DSA: niciun HSM de pe piata la data scrierii nu le expune prin PKCS#11 standard); `pkcs11-tool` lucreaza cu fisiere, deci
// radacina trece cateva milisecunde printr-un fisier din /dev/shm (memorie, nu disc), umplut cu zerouri si sters imediat. Integritatea:
// mecanismul folosit e AES-CBC-PAD (OpenSC 0.25 nu expune AES-GCM la cifrare), deci radacina se sigileaza impreuna cu 16 octeti din
// amprenta ei; o sigilare atinsa sau deschisa cu alta cheie da REFUZ numit, nu o radacina gresita (care oricum ar fi refuzata de
// verificarea radacinii din kms.mjs).
//
// node hsm-radacina.mjs sigileaza --modul <lib.so> --token <eticheta> --id <hex> --iesire <fisier> [--nou]
// radacina: AERE_KMS_ROOT_KEY (o radacina existenta) sau --nou (una noua, aleatoare); PIN-ul: AERE_HSM_PIN
// node hsm-radacina.mjs verifica <fisier> deschide sigilarea si spune doar DA/NU (nu tipareste niciodata radacina);
// biblioteca: AERE_HSM_MODULE (trebuie sa fie exact cea din fisier), PIN-ul: AERE_HSM_PIN
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { KmsError, parseRootKey } from './kms.mjs';
export const FORMAT_HSM = 'aerekms-root-hsm/1';
// 2026-09-29, revizuirea adversariala a felii HSM (pista B), inainte de publicare:
// H1: fisierul sigilat numea biblioteca PKCS#11 (`modul`) si variabila PIN-ului (`pinEnv`), iar deschiderea le credea. Cine poate scrie
// fisierul (sta pe disc, e facut sa stea acolo) facea KMS-ul sa incarce ORICE biblioteca la pornire (cod strain in procesul KMS) si
// sa-i dea PIN-ul, sau alta variabila de mediu (de ex. jetonul API) drept PIN. Acum biblioteca vine din configuratia procesului
// (AERE_HSM_MODULE, cale absoluta) si trebuie sa fie EXACT cea din fisier, verificat INAINTE de orice rulare a uneltei; variabila
// PIN-ului e fixa (AERE_HSM_PIN), fisierul nu o poate alege.
// H2: cele doua drumuri de refuz ale unei sigilari atinse (umplutura CBC stricata / amprenta gresita) aveau MESAJE diferite; cine poate
// modifica fisierul si citi jurnalul pornirii avea un oracol de umplutura pe AES-CBC-PAD. Acum acelasi cod si acelasi mesaj.
export const PIN_ENV = 'AERE_HSM_PIN';
const MESAJ_ATINS = 'the sealed root does not open to the sealed root with this HSM key (modified, or sealed by another key)';
const ETICHETA = /^[A-Za-z0-9._-]{1,32}$/;
const ID_HEX = /^[0-9a-fA-F]{2,64}$/;
const amprenta = (r) => crypto.createHash('sha256').update(Buffer.concat([Buffer.from(FORMAT_HSM + '\0', 'utf8'), r])).digest().subarray(0, 16);
function dosarRam() {
const baza = fs.existsSync('/dev/shm') ? '/dev/shm' : os.tmpdir();
return fs.mkdtempSync(path.join(baza, 'aerekms-hsm-'));
}
function stergeSigur(f) {
try { const n = fs.statSync(f).size; fs.writeFileSync(f, Buffer.alloc(n)); } catch { /* nu exista */ }
try { fs.rmSync(f, { force: true }); } catch { /* */ }
}
function pkcs11(unealta, args, env) {
const r = spawnSync(unealta, args, { env, encoding: 'utf8', timeout: 60_000 });
if (r.error && r.error.code === 'ENOENT') throw new KmsError('HSM_TOOL_MISSING', `${unealta} is not installed (OpenSC)`);
if (r.error) throw new KmsError('HSM_TOOL_FAILED', `${unealta} could not run: ${r.error.code || 'error'}`);
const err = (r.stderr || '') + (r.stdout || '');
if (r.status !== 0) {
if (/CKR_PIN_INCORRECT|CKR_PIN_LEN_RANGE|CKR_PIN_LOCKED|Login failed/i.test(err)) throw new KmsError('HSM_LOGIN_REFUSED', 'the HSM refused the PIN');
if (/No slot|token.*not found|Can't find|No token/i.test(err)) throw new KmsError('HSM_TOKEN_NOT_FOUND', 'the HSM token or key was not found');
throw new KmsError('HSM_OPERATION_FAILED', 'the HSM refused the operation');
}
return r;
}
function comuni({ modul, token, idCheie, pinEnv }) {
if (typeof modul !== 'string' || !path.isAbsolute(modul)) throw new KmsError('HSM_CONFIG_INVALID', 'modul must be the absolute path of the PKCS#11 library');
if (!ETICHETA.test(String(token || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'token label must match ^[A-Za-z0-9._-]{1,32}$');
if (!ID_HEX.test(String(idCheie || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'key id must be hex');
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(String(pinEnv))) throw new KmsError('HSM_CONFIG_INVALID', 'pinEnv must name an environment variable');
return ['--module', modul, '--token-label', token, '--login', '--pin', 'env:' + pinEnv, '--id', idCheie, '--mechanism', 'AES-CBC-PAD'];
}
/** sigileaza o radacina de 32 de octeti cu cheia AES a HSM-ului; intoarce sigilarea (JSON), niciodata radacina */
export function sigileazaRadacina({ modul, token, idCheie, radacina, env = process.env, unealta = 'pkcs11-tool' }) {
const pinEnv = PIN_ENV;
if (!Buffer.isBuffer(radacina) || radacina.length !== 32 || radacina.every((x) => x === 0)) throw new KmsError('ROOT_KEY_INVALID', 'the root to seal must be 32 non-zero bytes');
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
const a = comuni({ modul, token, idCheie, pinEnv });
const iv = crypto.randomBytes(16);
const d = dosarRam(); const pt = path.join(d, 'r'), ct = path.join(d, 'c');
try {
fs.writeFileSync(pt, Buffer.concat([radacina, amprenta(radacina)]), { mode: 0o600 });
pkcs11(unealta, [...a, '--encrypt', '--iv', iv.toString('hex'), '--input-file', pt, '--output-file', ct], env);
const c = fs.readFileSync(ct);
if (c.length !== 64) throw new KmsError('HSM_OPERATION_FAILED', `unexpected sealed length ${c.length}`);
return { format: FORMAT_HSM, mecanism: 'AES-CBC-PAD', modul, token, idCheie: idCheie.toLowerCase(), pinEnv, iv: iv.toString('hex'), ct: c.toString('hex'), creat: new Date().toISOString() };
} finally { stergeSigur(pt); stergeSigur(ct); fs.rmSync(d, { recursive: true, force: true }); }
}
/** deschide o sigilare cu HSM-ul; intoarce radacina (Buffer de 32) sau arunca un refuz NUMIT */
export function deschideRadacina(sig, { env = process.env, unealta = 'pkcs11-tool' } = {}) {
if (!sig || sig.format !== FORMAT_HSM || sig.mecanism !== 'AES-CBC-PAD') throw new KmsError('HSM_SEAL_INVALID', `the sealed root is not ${FORMAT_HSM}`);
if (!/^[0-9a-f]{32}$/.test(String(sig.iv)) || !/^[0-9a-f]{128}$/.test(String(sig.ct))) throw new KmsError('HSM_SEAL_INVALID', 'the sealed root has a malformed iv or ciphertext');
// H1: biblioteca si variabila PIN-ului NU se iau din fisier; nimic nu ruleaza pana nu trec ambele verificari
const permis = String(env.AERE_HSM_MODULE || '').trim();
if (!permis || !path.isAbsolute(permis)) throw new KmsError('HSM_CONFIG_INVALID', 'AERE_HSM_MODULE must name the PKCS#11 library (absolute path); the sealed file alone does not choose it');
if (sig.modul !== permis) throw new KmsError('HSM_MODULE_NOT_ALLOWED', 'the sealed root names a PKCS#11 library other than AERE_HSM_MODULE; it is not loaded');
if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV) throw new KmsError('HSM_SEAL_INVALID', `the PIN is always read from ${PIN_ENV}; a sealed file cannot choose another variable`);
const pinEnv = PIN_ENV;
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
const a = comuni({ modul: permis, token: sig.token, idCheie: sig.idCheie, pinEnv });
const d = dosarRam(); const ct = path.join(d, 'c'), pt = path.join(d, 'r');
try {
fs.writeFileSync(ct, Buffer.from(sig.ct, 'hex'), { mode: 0o600 });
try { pkcs11(unealta, [...a, '--decrypt', '--iv', sig.iv, '--input-file', ct, '--output-file', pt], env); }
catch (e) { if (e.code === 'HSM_OPERATION_FAILED') throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); throw e; }
const p = fs.readFileSync(pt);
const r = Buffer.from(p.subarray(0, 32)); const t = p.subarray(32);
const bun = p.length === 48 && crypto.timingSafeEqual(t, amprenta(r));
p.fill(0);
if (!bun) { r.fill(0); throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); }
return r;
} finally { stergeSigur(ct); stergeSigur(pt); fs.rmSync(d, { recursive: true, force: true }); }
}
/** din mediu: AERE_KMS_ROOT_HSM (calea sigilarii) -> radacina in hex, exact forma pe care o primea kms.mjs din AERE_KMS_ROOT_KEY */
export function radacinaDinHsm(env, optiuni = {}) {
const f = String(env.AERE_KMS_ROOT_HSM || '').trim();
let sig;
try { sig = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { throw new KmsError('HSM_SEAL_INVALID', 'AERE_KMS_ROOT_HSM does not point to a readable sealed root'); }
const r = deschideRadacina(sig, { env, ...optiuni });
const h = r.toString('hex'); r.fill(0);
return h;
}
async function cli(argv) {
const [cmd, ...rest] = argv;
const opt = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
try {
if (cmd === 'sigileaza') {
const iesire = opt('--iesire'); if (!iesire) throw new KmsError('HSM_CONFIG_INVALID', '--iesire <file> is required');
if (fs.existsSync(iesire)) throw new KmsError('HSM_CONFIG_INVALID', 'the output file exists; a sealed root is never overwritten');
const radacina = rest.includes('--nou') ? crypto.randomBytes(32) : parseRootKey(process.env.AERE_KMS_ROOT_KEY);
const sig = sigileazaRadacina({ modul: opt('--modul'), token: opt('--token'), idCheie: opt('--id'), radacina });
radacina.fill(0);
fs.writeFileSync(iesire, JSON.stringify(sig, null, 1) + '\n', { mode: 0o600, flag: 'wx' });
console.log(`sealed root written to ${iesire} (${FORMAT_HSM}, token ${sig.token}, key id ${sig.idCheie})`);
} else if (cmd === 'verifica') {
const r = deschideRadacina(JSON.parse(fs.readFileSync(rest[0], 'utf8'))); r.fill(0);
console.log('DA: the sealed root opens with this HSM and PIN');
} else { console.error('usage: node hsm-radacina.mjs sigileaza --modul <lib> --token <label> --id <hex> --iesire <file> [--nou] | verifica <file> (PIN in AERE_HSM_PIN; verifica also needs AERE_HSM_MODULE)'); process.exitCode = 2; }
} catch (e) { console.error(`NU: ${e.code || 'ERROR'}: ${e.message}`); process.exitCode = 1; }
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) cli(process.argv.slice(2));

912
pq-kms/kms.mjs Normal file
View File

@ -0,0 +1,912 @@
// kms.mjs - Aere PQ KMS: un KMS de tip "transit" (ca Vault transit), hibrid clasic + post-cuantic.
//
// Numai node:crypto (Node 24, OpenSSL 3.5), fara dependinte.
//
// chei "encrypt": X25519 + ML-KEM-768, KEM hibrid; secretul plicului se deriva cu HKDF-SHA-256
// peste AMBELE secrete partajate si peste transcriptul ambelor encapsulari,
// legat de nume + versiune + aad; apoi AES-256-GCM.
// chei "sign": Ed25519 + ML-DSA-65; semnatura poarta AMBELE jumatati si verificarea le cere
// pe amandoua.
//
// Cheile private stau pe disc sigilate cu AES-256-GCM sub o cheie derivata din AERE_KMS_ROOT_KEY.
// Fara cheia radacina constructorul refuza (nu se genereaza nicio cheie in tacere).
// Fiecare operatie scrie un rand in jurnalul de audit inlantuit (HMAC peste rand + mac-ul
// randului anterior), fara date clare si fara material de cheie.
//
// Niciun mesaj de eroare nu contine material de cheie: mesajele sunt texte fixe plus nume de
// chei si numere de versiune.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const PREFIX = 'aerekms';
export const KEY_FORMAT = 'aerekms-key/1';
const ZERO_MAC = '0'.repeat(64);
// Etichetele de domeniu. Fac parte din format: un tert care vrea interoperabilitate le
// foloseste exact asa (README, sectiunea "Wire format").
export const LABELS = Object.freeze({
root: 'aerekms/v1/root',
seal: 'aerekms/v1/seal-private',
sealAad: 'aerekms/v1/seal',
fileMac: 'aerekms/v1/key-file-mac',
audit: 'aerekms/v1/audit-chain',
rootCheck: 'aerekms/v1/root-check',
fp: 'aerekms/v1/fingerprint',
kem: 'aerekms/v1/hybrid-kem',
commit: 'aerekms/v1/commit',
aad: 'aerekms/v1/aad',
dek: 'aerekms/v1/dek',
sig: 'aerekms/v1/hybrid-sig',
sigAlg: 'ed25519+ml-dsa-65',
sigCtx: 'aerekms/v1',
});
// Marimi masurate pe Node 24.14.1 / OpenSSL 3.5.5 (2026-09-25).
const SZ = Object.freeze({ x: 32, ek: 1184, ctK: 1088, ed: 64, pkDsa: 1952, mlSig: 3309, fp: 8 });
// Antetele SPKI sunt fixe (22 de octeti); le verificam octet cu octet, nu doar lungimea.
const SPKI_HDR = Object.freeze({
'ml-kem-768': Buffer.from('308204b2300b0609608648016503040402038204a100', 'hex'),
'ml-dsa-65': Buffer.from('308207b2300b0609608648016503040312038207a100', 'hex'),
});
const MAGIC_E = Buffer.from('AKM1', 'ascii');
const MAGIC_S = Buffer.from('AKS1', 'ascii');
const KIND_E = 0x45; // 'E'
const KIND_S = 0x53; // 'S'
// Plicul de criptare:
// magic(4) | kind(1) | version u32be(4) | fp(8) | ePub X25519(32) | ct ML-KEM(1088)
// | aadTag(16) | commit(16) | payload AES-256-GCM (n) | gcmTag(16)
const OFF = Object.freeze({ ver: 5, fp: 9, ePub: 17, ctK: 49, aadTag: 1137, commit: 1153, payload: 1169 });
const ENV_MIN = OFF.payload + 16;
// Semnatura: magic(4) | kind(1) | version(4) | fp(8) | Ed25519(64) | ML-DSA-65(3309)
const SIG_LEN = 17 + SZ.ed + SZ.mlSig;
const MAX_PLAINTEXT = 1024 * 1024;
const MAX_MESSAGE = 1024 * 1024;
const MAX_AAD = 64 * 1024;
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const ENV_RE = /^aerekms:v([1-9][0-9]{0,8}):([A-Za-z0-9+/]+={0,2})$/;
export class KmsError extends Error {
constructor(code, message, extra) {
super(message);
this.name = 'KmsError';
this.code = code;
if (extra) Object.assign(this, extra);
}
}
// ---------------------------------------------------------------------------------------------
// primitive mici
function u32(n) {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0);
return b;
}
function lp(b) {
const x = Buffer.from(b);
return Buffer.concat([u32(x.length), x]);
}
function utf8(s) {
return Buffer.from(s, 'utf8');
}
function sha256(...parts) {
const h = crypto.createHash('sha256');
for (const p of parts) h.update(p);
return h.digest();
}
function hmac(key, ...parts) {
const h = crypto.createHmac('sha256', key);
for (const p of parts) h.update(p);
return h.digest();
}
function hkdf(ikm, salt, info, len) {
return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, len));
}
function ctEq(a, b) {
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export function canonicalJson(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}';
}
function writeFileAtomic(p, text) {
const tmp = `${p}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
const fd = fs.openSync(tmp, 'w', 0o600);
try {
fs.writeSync(fd, text);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, p);
}
// ---------------------------------------------------------------------------------------------
// cheia radacina
// Citeste AERE_KMS_ROOT_KEY. Refuza lipsa, forma gresita si cheia de zerouri. Mesajul spune
// cel mult LUNGIMEA valorii, niciodata valoarea.
export function parseRootKey(value) {
const s = value === undefined || value === null ? '' : String(value).trim();
if (s === '') throw new KmsError('ROOT_KEY_MISSING', 'AERE_KMS_ROOT_KEY is not set; refusing to start (a root key is never generated automatically)');
if (!/^[0-9a-fA-F]{64}$/.test(s)) {
throw new KmsError('ROOT_KEY_INVALID', `AERE_KMS_ROOT_KEY must be exactly 64 hexadecimal characters (32 bytes); the value given has ${s.length} characters or contains non-hex characters`);
}
const b = Buffer.from(s, 'hex');
if (b.every((x) => x === 0)) throw new KmsError('ROOT_KEY_WEAK', 'AERE_KMS_ROOT_KEY is all zeros; refusing to start');
return b;
}
// ---------------------------------------------------------------------------------------------
// intrari
function checkName(name) {
if (typeof name !== 'string' || !NAME_RE.test(name)) {
throw new KmsError('INVALID_KEY_NAME', 'key name must match ^[a-z0-9][a-z0-9_-]{0,63}$');
}
return name;
}
function toBytes(v, what, max) {
let b;
if (Buffer.isBuffer(v) || v instanceof Uint8Array) b = Buffer.from(v);
else if (typeof v === 'string') b = utf8(v);
else throw new KmsError('INVALID_INPUT', `${what} must be a Buffer, Uint8Array or string`);
if (b.length > max) throw new KmsError(`${what.toUpperCase()}_TOO_LARGE`, `${what} is larger than ${max} bytes`);
return b;
}
function normAad(aad) {
if (aad === undefined || aad === null) return Buffer.alloc(0);
return toBytes(aad, 'aad', MAX_AAD);
}
// ---------------------------------------------------------------------------------------------
// codificarea cheilor publice
function rawOkp(pub) {
return Buffer.from(pub.export({ format: 'jwk' }).x, 'base64url');
}
function okpPublic(raw, crv) {
return crypto.createPublicKey({ key: { kty: 'OKP', crv, x: Buffer.from(raw).toString('base64url') }, format: 'jwk' });
}
function rawFromSpki(spki, alg) {
const hdr = SPKI_HDR[alg];
if (!spki.subarray(0, hdr.length).equals(hdr)) throw new KmsError('INTERNAL', `unexpected SPKI encoding for ${alg}`);
return spki.subarray(hdr.length);
}
function fingerprint(kind, name, ver, pubA, pubB) {
return sha256(utf8(LABELS.fp + '\0'), Buffer.from([kind]), lp(utf8(name)), u32(ver), pubA, pubB).subarray(0, SZ.fp);
}
function sealAad(name, type, ver, fpHex) {
return utf8(`${LABELS.sealAad}\0${name}\0${type}\0${ver}\0${fpHex}`);
}
// ---------------------------------------------------------------------------------------------
// KEM hibrid
// Transcriptul leaga: numele cheii, versiunea, amprenta, cheia X25519 a destinatarului,
// amprenta cheii ML-KEM a destinatarului, cheia X25519 efemera si textul cifrat ML-KEM.
function kemTranscript(name, ver, fp, xPub, ekRaw, ePub, ctK) {
return Buffer.concat([utf8(LABELS.kem + '\0'), lp(utf8(name)), u32(ver), fp, xPub, sha256(ekRaw), ePub, ctK]);
}
// Secretul plicului: HKDF-SHA-256 cu IKM = ss_ML-KEM || ss_X25519 si sare = SHA-256(transcript).
// Din el ies: cheia de angajament (commit), cheia etichetei aad si cheia AES + IV-ul, ultima
// legata si de aad prin info.
function deriveKeys(ssK, ssX, transcript, aad) {
const ikm = Buffer.concat([ssK, ssX]);
const salt = sha256(transcript);
const commitKey = hkdf(ikm, salt, utf8(LABELS.commit), 32);
const aadKey = hkdf(ikm, salt, utf8(LABELS.aad), 32);
const dek = hkdf(ikm, salt, Buffer.concat([utf8(LABELS.dek + '\0'), sha256(aad)]), 44);
ikm.fill(0);
return { commitKey, aadTag: hmac(aadKey, aad).subarray(0, 16), key: dek.subarray(0, 32), iv: dek.subarray(32, 44), dek };
}
function sigMessage(fp, ver, message) {
return Buffer.concat([utf8(LABELS.sig + '\0' + LABELS.sigAlg + '\0'), fp, u32(ver), message]);
}
function parseEnvelope(str, kind) {
const what = kind === KIND_E ? 'ciphertext' : 'signature';
const bad = kind === KIND_E ? 'MALFORMED_CIPHERTEXT' : 'MALFORMED_SIGNATURE';
if (typeof str !== 'string') throw new KmsError(bad, `${what} must be a string of the form aerekms:v<version>:<base64>`);
const m = ENV_RE.exec(str);
if (!m) throw new KmsError(bad, `${what} is not of the form aerekms:v<version>:<base64>`);
const verPrefix = Number(m[1]);
const body = Buffer.from(m[2], 'base64');
if (body.toString('base64') !== m[2]) throw new KmsError(bad, `${what} uses non-canonical base64`);
const magic = kind === KIND_E ? MAGIC_E : MAGIC_S;
if (body.length < 17 || !body.subarray(0, 4).equals(magic) || body[4] !== kind) {
throw new KmsError(bad, `${what} does not carry the expected ${kind === KIND_E ? 'AKM1/E' : 'AKS1/S'} header`);
}
const verBody = body.readUInt32BE(OFF.ver);
if (verBody !== verPrefix) {
throw new KmsError('VERSION_MISMATCH', `the version in the prefix (v${verPrefix}) does not match the version inside the ${what} (v${verBody})`, { version: verPrefix });
}
return { ver: verBody, body, fp: body.subarray(OFF.fp, OFF.fp + SZ.fp) };
}
function describe(key) {
const versions = {};
for (const v of Object.keys(key.versions)) {
const r = key.versions[v];
versions[v] = { created: r.created, fingerprint: r.fingerprint, public: { ...r.public } };
}
return {
name: key.name,
type: key.type,
created: key.created,
policy: { ...key.policy },
min_decryption_version: key.min_decryption_version,
latest_version: key.latest_version,
versions,
};
}
const VERIFY_REFUSALS = new Set(['MALFORMED_SIGNATURE', 'VERSION_MISMATCH', 'UNKNOWN_VERSION', 'VERSION_BELOW_MINIMUM', 'KEY_MISMATCH']);
// ---------------------------------------------------------------------------------------------
export function openKms(opts) {
return new Kms(opts);
}
export class Kms {
#dir;
#keysDir;
#auditPath;
#sealKey;
#fileKey;
#auditKey;
#head;
#keys = new Map();
#priv = new Map();
#pub = new Map();
#closed = false;
constructor({ dataDir, rootKey } = {}) {
// Intai cheia radacina: fara ea nu se atinge discul deloc.
const root = parseRootKey(rootKey);
if (typeof dataDir !== 'string' || dataDir === '') {
root.fill(0);
throw new KmsError('DATA_DIR_MISSING', 'dataDir is required');
}
const sub = (label) => hkdf(root, utf8(LABELS.root), utf8(label), 32);
this.#sealKey = sub(LABELS.seal);
this.#fileKey = sub(LABELS.fileMac);
this.#auditKey = sub(LABELS.audit);
const checkKey = sub(LABELS.rootCheck);
root.fill(0);
this.#dir = path.resolve(dataDir);
this.#keysDir = path.join(this.#dir, 'keys');
this.#auditPath = path.join(this.#dir, 'audit.log');
fs.mkdirSync(this.#keysDir, { recursive: true, mode: 0o700 });
this.#checkRoot(checkKey);
const v = this.verifyAudit();
if (!v.ok) {
throw new KmsError('AUDIT_CHAIN_INVALID', `the audit log failed verification at line ${v.line} (${v.reason}); refusing to start. Move audit.log aside (keep it as evidence) to start a new chain.`);
}
this.#head = { seq: v.head.seq, mac: v.head.mac };
}
get dataDir() {
return this.#dir;
}
close() {
this.#closed = true;
this.#priv.clear();
this.#keys.clear();
this.#pub.clear();
}
// ----------------------------------------------------------------------- radacina si fisiere
#checkRoot(checkKey) {
const p = path.join(this.#dir, 'root-check.json');
const expected = hmac(checkKey, utf8('aerekms root key check')).toString('hex');
checkKey.fill(0);
if (fs.existsSync(p)) {
let rec;
try {
rec = JSON.parse(fs.readFileSync(p, 'utf8'));
} catch {
throw new KmsError('ROOT_CHECK_UNREADABLE', 'root-check.json is not valid JSON; refusing to start');
}
if (!rec || typeof rec.check !== 'string' || !ctEq(utf8(rec.check), utf8(expected))) {
throw new KmsError('ROOT_KEY_MISMATCH', 'AERE_KMS_ROOT_KEY does not match the key this data directory was created with; refusing to start');
}
return;
}
const existing = fs.readdirSync(this.#keysDir).filter((f) => f.endsWith('.json'));
if (existing.length > 0 || fs.existsSync(this.#auditPath)) {
throw new KmsError('ROOT_CHECK_MISSING', 'the data directory has keys or an audit log but no root-check.json; refusing to start');
}
writeFileAtomic(p, JSON.stringify({ format: 'aerekms-root-check/1', check: expected }) + '\n');
}
#keyPath(name) {
return path.join(this.#keysDir, `${name}.json`);
}
#fileMac(obj) {
return hmac(this.#fileKey, utf8(canonicalJson(obj))).toString('hex');
}
#load(name, wantType) {
checkName(name);
let key = this.#keys.get(name);
if (!key) {
const p = this.#keyPath(name);
if (!fs.existsSync(p)) throw new KmsError('KEY_NOT_FOUND', `key "${name}" does not exist`);
let rec;
try {
rec = JSON.parse(fs.readFileSync(p, 'utf8'));
} catch {
throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" is not valid JSON`);
}
const { mac, ...rest } = rec || {};
const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(this.#fileMac(rest)));
if (!macOk) throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" failed its integrity check`);
if (rest.name !== name || rest.format !== KEY_FORMAT) {
throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" belongs to another key or format`);
}
key = rest;
this.#keys.set(name, key);
}
if (wantType && key.type !== wantType) {
throw new KmsError('WRONG_KEY_TYPE', `key "${name}" is a ${key.type} key; this operation needs a ${wantType} key`);
}
return key;
}
#save(key) {
const rec = { ...key, mac: this.#fileMac(key) };
writeFileAtomic(this.#keyPath(key.name), JSON.stringify(rec, null, 2) + '\n');
this.#keys.set(key.name, key);
}
// ----------------------------------------------------------------------- sigilarea privatelor
#seal(plain, aad) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', this.#sealKey, iv);
c.setAAD(aad);
const ct = Buffer.concat([c.update(plain), c.final()]);
plain.fill(0);
return Buffer.concat([iv, ct, c.getAuthTag()]).toString('base64');
}
#unseal(sealed, aad) {
try {
const b = Buffer.from(sealed, 'base64');
const d = crypto.createDecipheriv('aes-256-gcm', this.#sealKey, b.subarray(0, 12));
d.setAAD(aad);
d.setAuthTag(b.subarray(b.length - 16));
return Buffer.concat([d.update(b.subarray(12, b.length - 16)), d.final()]);
} catch {
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be unsealed (wrong root key or modified key file)');
}
}
#newVersion(key) {
const ver = key.latest_version + 1;
let kind, pubA, pubB, derA, derB, pub;
if (key.type === 'encrypt') {
kind = KIND_E;
const a = crypto.generateKeyPairSync('x25519');
const b = crypto.generateKeyPairSync('ml-kem-768');
const spki = b.publicKey.export({ format: 'der', type: 'spki' });
pubA = rawOkp(a.publicKey);
pubB = rawFromSpki(spki, 'ml-kem-768');
pub = { x25519: pubA.toString('base64'), ml_kem_768: spki.toString('base64') };
derA = a.privateKey.export({ format: 'der', type: 'pkcs8' });
derB = b.privateKey.export({ format: 'der', type: 'pkcs8' });
} else {
kind = KIND_S;
const a = crypto.generateKeyPairSync('ed25519');
const b = crypto.generateKeyPairSync('ml-dsa-65');
const spki = b.publicKey.export({ format: 'der', type: 'spki' });
pubA = rawOkp(a.publicKey);
pubB = rawFromSpki(spki, 'ml-dsa-65');
pub = { ed25519: pubA.toString('base64'), ml_dsa_65: spki.toString('base64') };
derA = a.privateKey.export({ format: 'der', type: 'pkcs8' });
derB = b.privateKey.export({ format: 'der', type: 'pkcs8' });
}
const fp = fingerprint(kind, key.name, ver, pubA, pubB);
const fpHex = fp.toString('hex');
const privJson = JSON.stringify({ a: derA.toString('base64'), b: derB.toString('base64') });
const sealed = this.#seal(utf8(privJson), sealAad(key.name, key.type, ver, fpHex));
derA.fill(0);
derB.fill(0);
key.versions[String(ver)] = {
created: new Date().toISOString(),
fingerprint: fpHex,
public: pub,
private_sealed: sealed,
};
key.latest_version = ver;
return ver;
}
#privateKeys(key, ver) {
const id = `${key.name}:${ver}`;
const cached = this.#priv.get(id);
if (cached) return cached;
const v = key.versions[String(ver)];
const plain = this.#unseal(v.private_sealed, sealAad(key.name, key.type, ver, v.fingerprint));
let obj;
try {
obj = JSON.parse(plain.toString('utf8'));
} catch {
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has an unexpected form');
} finally {
plain.fill(0);
}
const derA = Buffer.from(obj.a, 'base64');
const derB = Buffer.from(obj.b, 'base64');
let k;
try {
k = {
a: crypto.createPrivateKey({ key: derA, format: 'der', type: 'pkcs8' }),
b: crypto.createPrivateKey({ key: derB, format: 'der', type: 'pkcs8' }),
};
} catch {
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be imported');
} finally {
derA.fill(0);
derB.fill(0);
}
const want = key.type === 'encrypt' ? ['x25519', 'ml-kem-768'] : ['ed25519', 'ml-dsa-65'];
if (k.a.asymmetricKeyType !== want[0] || k.b.asymmetricKeyType !== want[1]) {
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has the wrong algorithm');
}
this.#priv.set(id, k);
return k;
}
#publicKeys(key, ver) {
const id = `${key.name}:${ver}`;
const cached = this.#pub.get(id);
if (cached) return cached;
const v = key.versions[String(ver)];
let r;
if (key.type === 'encrypt') {
const rawA = Buffer.from(v.public.x25519, 'base64');
const spki = Buffer.from(v.public.ml_kem_768, 'base64');
r = { rawA, rawB: rawFromSpki(spki, 'ml-kem-768'), a: okpPublic(rawA, 'X25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) };
} else {
const rawA = Buffer.from(v.public.ed25519, 'base64');
const spki = Buffer.from(v.public.ml_dsa_65, 'base64');
r = { rawA, rawB: rawFromSpki(spki, 'ml-dsa-65'), a: okpPublic(rawA, 'Ed25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) };
}
r.fp = Buffer.from(v.fingerprint, 'hex');
this.#pub.set(id, r);
return r;
}
// ----------------------------------------------------------------------- versiuni
#checkVersion(key, ver) {
if (!Object.hasOwn(key.versions, String(ver))) {
throw new KmsError('UNKNOWN_VERSION', `key "${key.name}" has no version ${ver}`, { version: ver });
}
if (ver < key.min_decryption_version) throw new KmsError('VERSION_BELOW_MINIMUM', `version ${ver} of key "${key.name}" is below min_decryption_version ${key.min_decryption_version}`, { version: ver });
}
#checkFingerprint(key, ver, fp, what) {
const fpExpected = Buffer.from(key.versions[String(ver)].fingerprint, 'hex');
if (!fp.equals(fpExpected)) {
const other = Object.keys(key.versions).find((v) => Buffer.from(key.versions[v].fingerprint, 'hex').equals(fp));
if (other !== undefined) {
throw new KmsError('VERSION_MISMATCH', `the ${what} was produced by version ${other} of key "${key.name}", not by version ${ver}`, { version: ver });
}
throw new KmsError('KEY_MISMATCH', `the ${what} was not produced by key "${key.name}"`, { version: ver });
}
}
// ----------------------------------------------------------------------- plicul
#encryptWith(key, pt, aad) {
const ver = key.latest_version;
const pk = this.#publicKeys(key, ver);
const eph = crypto.generateKeyPairSync('x25519');
const ePub = rawOkp(eph.publicKey);
const ssX = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: pk.a });
const { sharedKey: ssK, ciphertext: ctK } = crypto.encapsulate(pk.b);
const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad);
ssX.fill(0);
ssK.fill(0);
const pre = Buffer.concat([MAGIC_E, Buffer.from([KIND_E]), u32(ver), pk.fp, ePub, ctK, d.aadTag]);
const commit = hmac(d.commitKey, pre).subarray(0, 16);
const hdr = Buffer.concat([pre, commit]);
const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv);
c.setAAD(hdr);
const ct = Buffer.concat([c.update(pt), c.final()]);
const body = Buffer.concat([hdr, ct, c.getAuthTag()]);
d.dek.fill(0);
return { ciphertext: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver };
}
#decryptWith(key, ctStr, aad) {
const { ver, body, fp } = parseEnvelope(ctStr, KIND_E);
try {
if (body.length < ENV_MIN) throw new KmsError('MALFORMED_CIPHERTEXT', 'ciphertext is too short');
this.#checkVersion(key, ver);
this.#checkFingerprint(key, ver, fp, 'ciphertext');
const pk = this.#publicKeys(key, ver);
const sk = this.#privateKeys(key, ver);
const ePub = body.subarray(OFF.ePub, OFF.ePub + SZ.x);
const ctK = body.subarray(OFF.ctK, OFF.ctK + SZ.ctK);
const aadTag = body.subarray(OFF.aadTag, OFF.aadTag + 16);
const commit = body.subarray(OFF.commit, OFF.commit + 16);
let ssX, ssK;
try {
ssX = crypto.diffieHellman({ privateKey: sk.a, publicKey: okpPublic(ePub, 'X25519') });
ssK = crypto.decapsulate(sk.b, ctK);
} catch {
throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version');
}
const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad);
ssX.fill(0);
ssK.fill(0);
try {
if (!ctEq(hmac(d.commitKey, body.subarray(0, OFF.commit)).subarray(0, 16), commit)) {
throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version');
}
if (!ctEq(d.aadTag, aadTag)) {
throw new KmsError('AAD_MISMATCH', 'the additional authenticated data (aad) does not match the one used at encryption');
}
let plaintext;
try {
const dc = crypto.createDecipheriv('aes-256-gcm', d.key, d.iv);
dc.setAAD(body.subarray(0, OFF.payload));
dc.setAuthTag(body.subarray(body.length - 16));
plaintext = Buffer.concat([dc.update(body.subarray(OFF.payload, body.length - 16)), dc.final()]);
} catch {
throw new KmsError('PAYLOAD_AUTH_FAILED', 'the encrypted payload or its authentication tag was modified');
}
return { plaintext, version: ver };
} finally {
d.dek.fill(0);
}
} catch (e) {
if (e instanceof KmsError && e.version === undefined) e.version = ver;
throw e;
}
}
#verifyWith(key, msg, signature) {
const { ver, body, fp } = parseEnvelope(signature, KIND_S);
if (body.length !== SIG_LEN) throw new KmsError('MALFORMED_SIGNATURE', `signature must be exactly ${SIG_LEN} bytes after base64 decoding`, { version: ver });
this.#checkVersion(key, ver);
this.#checkFingerprint(key, ver, fp, 'signature');
const pk = this.#publicKeys(key, ver);
const m = sigMessage(pk.fp, ver, msg);
const edSig = body.subarray(17, 17 + SZ.ed);
const mlSig = body.subarray(17 + SZ.ed);
const safe = (f) => {
try {
return f() === true;
} catch {
return false;
}
};
const edOk = safe(() => crypto.verify(null, m, pk.a, edSig));
const pqOk = safe(() => crypto.verify(null, m, { key: pk.b, context: utf8(LABELS.sigCtx) }, mlSig));
const valid = edOk && pqOk;
let reason = null;
if (!edOk && !pqOk) reason = 'BOTH_SIGNATURES_INVALID';
else if (!edOk) reason = 'CLASSICAL_SIGNATURE_INVALID';
else if (!pqOk) reason = 'PQ_SIGNATURE_INVALID';
return { valid, reason, version: ver, classical: edOk, post_quantum: pqOk };
}
// ----------------------------------------------------------------------- jurnalul de audit
#audit(e) {
const row = {
seq: this.#head.seq + 1,
ts: new Date().toISOString(),
op: e.op,
key: e.key ?? null,
version: e.version ?? null,
ok: e.ok === true,
reason: e.reason ?? null,
prev: this.#head.mac,
};
row.mac = hmac(this.#auditKey, utf8(canonicalJson(row))).toString('hex');
try {
const fd = fs.openSync(this.#auditPath, 'a', 0o600);
try {
fs.writeSync(fd, JSON.stringify(row) + '\n');
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
} catch {
throw new KmsError('AUDIT_WRITE_FAILED', 'the audit log could not be written; the result of the operation is withheld');
}
this.#head = { seq: row.seq, mac: row.mac };
}
// Ruleaza o operatie si scrie randul ei de audit. fn intoarce { value, version, ok?, reason? }.
// Rezultatul nu iese din functie decat dupa ce randul de audit e scris.
#run(op, name, fn) {
if (this.#closed) throw new KmsError('KMS_CLOSED', 'this KMS instance was closed');
const keyName = typeof name === 'string' && NAME_RE.test(name) ? name : null;
// A4 (revizuirea din 2026-09-25): o MUTATIE (creare, rotire, minim de versiune) ramanea pe disc cand randul de audit nu se
// putea scrie, iar lantul de audit ramanea valid fara nicio urma a ei. Regula e "o schimbare sta numai daca randul ei sta":
// starea fisierului cheii se retine INAINTE de operatie si se pune la loc daca randul nu se poate scrie.
const keyFile = keyName ? this.#keyPath(keyName) : null;
const before = keyFile && fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null;
const rollback = () => {
if (!keyFile) return;
const after = fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null;
const changed = (before === null) !== (after === null) || (before !== null && after !== null && !before.equals(after));
if (!changed) return;
if (before === null) fs.rmSync(keyFile, { force: true });
else writeFileAtomic(keyFile, before);
this.#keys.delete(keyName);
};
let r;
try {
r = fn();
} catch (e0) {
let e = e0;
if (!(e instanceof KmsError)) {
e = new KmsError('INTERNAL', 'internal error');
Object.defineProperty(e, 'cause', { value: e0, enumerable: false });
}
try {
this.#audit({ op, key: keyName, version: e.version ?? null, ok: false, reason: e.code });
} catch (ea) {
rollback();
throw ea;
}
throw e;
}
try {
this.#audit({ op, key: keyName, version: r.version ?? null, ok: r.ok ?? true, reason: r.reason ?? null });
} catch (ea) {
rollback();
throw ea;
}
return r.value;
}
auditHead() {
return { seq: this.#head.seq, mac: this.#head.mac };
}
// Verifica tot jurnalul: fiecare rand trebuie sa aiba mac-ul corect, numarul de ordine urmator
// si mac-ul randului anterior. expectedHead = un cap {seq, mac} tinut in afara (prinde taierea
// cozii, pe care un lant singur nu o poate vedea).
verifyAudit({ expectedHead } = {}) {
const fail = (reason, line, extra) => ({ ok: false, reason, line, ...extra });
let lines = [];
if (fs.existsSync(this.#auditPath)) {
const text = fs.readFileSync(this.#auditPath, 'utf8');
if (text !== '') {
lines = text.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
else return fail('AUDIT_ROW_UNPARSABLE', lines.length);
}
}
let prev = ZERO_MAC;
let seq = -1;
for (let i = 0; i < lines.length; i++) {
let row;
try {
row = JSON.parse(lines[i]);
} catch {
return fail('AUDIT_ROW_UNPARSABLE', i + 1);
}
if (!row || typeof row !== 'object' || Array.isArray(row)) return fail('AUDIT_ROW_UNPARSABLE', i + 1);
const { mac, ...rest } = row;
const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(hmac(this.#auditKey, utf8(canonicalJson(rest))).toString('hex')));
if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1);
if (row.seq !== seq + 1 || row.prev !== prev) return fail('AUDIT_CHAIN_BROKEN', i + 1);
prev = mac;
seq = row.seq;
}
const head = { seq, mac: prev };
if (expectedHead) {
if (seq < expectedHead.seq) return fail('AUDIT_TRUNCATED', lines.length, { head });
const row = JSON.parse(lines[expectedHead.seq]);
if (row.mac !== expectedHead.mac) return fail('AUDIT_HEAD_MISMATCH', expectedHead.seq + 1, { head });
}
return { ok: true, rows: lines.length, head };
}
// ----------------------------------------------------------------------- cheile
createKey(name, { type, exportable = false } = {}) {
return this.#run('create_key', name, () => {
checkName(name);
if (type !== 'encrypt' && type !== 'sign') throw new KmsError('INVALID_KEY_TYPE', 'type must be "encrypt" or "sign"');
if (typeof exportable !== 'boolean') throw new KmsError('INVALID_POLICY', 'exportable must be a boolean');
if (this.#keys.has(name) || fs.existsSync(this.#keyPath(name))) throw new KmsError('KEY_EXISTS', `key "${name}" already exists`);
const key = {
format: KEY_FORMAT,
name,
type,
created: new Date().toISOString(),
policy: { exportable },
min_decryption_version: 1,
latest_version: 0,
versions: {},
};
const ver = this.#newVersion(key);
this.#save(key);
return { value: describe(key), version: ver };
});
}
getKey(name) {
return this.#run('read_key', name, () => {
const key = this.#load(name);
return { value: describe(key), version: key.latest_version };
});
}
listKeys() {
return this.#run('list_keys', null, () => {
const names = fs.readdirSync(this.#keysDir)
.filter((f) => f.endsWith('.json'))
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.sort();
return { value: names };
});
}
rotate(name) {
return this.#run('rotate', name, () => {
const key = structuredClone(this.#load(name));
const ver = this.#newVersion(key);
this.#save(key);
return { value: describe(key), version: ver };
});
}
// Minimul se poate numai RIDICA: o versiune retrasa nu mai decripteaza si nu mai verifica.
setMinDecryptionVersion(name, minVersion) {
return this.#run('config', name, () => {
const key = structuredClone(this.#load(name));
if (!Number.isSafeInteger(minVersion) || minVersion < 1 || minVersion > key.latest_version) {
throw new KmsError('VERSION_OUT_OF_RANGE', `min_decryption_version must be an integer between 1 and ${key.latest_version}`);
}
if (minVersion < key.min_decryption_version) {
throw new KmsError('MIN_VERSION_NOT_MONOTONIC', `min_decryption_version can only be raised (current: ${key.min_decryption_version})`);
}
key.min_decryption_version = minVersion;
this.#save(key);
return { value: describe(key), version: minVersion };
});
}
exportKey(name, version) {
return this.#run('export', name, () => {
const key = this.#load(name);
if (key.policy.exportable !== true) throw new KmsError('KEY_NOT_EXPORTABLE', `key "${name}" was not created as exportable`);
const ver = version === undefined || version === null ? key.latest_version : version;
if (!Number.isSafeInteger(ver) || !Object.hasOwn(key.versions, String(ver))) {
throw new KmsError('UNKNOWN_VERSION', `key "${name}" has no version ${ver}`);
}
const sk = this.#privateKeys(key, ver);
const der = (k) => k.export({ format: 'der', type: 'pkcs8' }).toString('base64');
const priv = key.type === 'encrypt'
? { x25519_pkcs8: der(sk.a), ml_kem_768_pkcs8: der(sk.b) }
: { ed25519_pkcs8: der(sk.a), ml_dsa_65_pkcs8: der(sk.b) };
return {
value: { name, type: key.type, version: ver, fingerprint: key.versions[String(ver)].fingerprint, private: priv },
version: ver,
};
});
}
// ----------------------------------------------------------------------- operatiile transit
encrypt(name, plaintext, aad) {
return this.#run('encrypt', name, () => {
const key = this.#load(name, 'encrypt');
const pt = toBytes(plaintext, 'plaintext', MAX_PLAINTEXT);
const r = this.#encryptWith(key, pt, normAad(aad));
pt.fill(0);
return { value: r, version: r.version };
});
}
decrypt(name, ciphertext, aad) {
return this.#run('decrypt', name, () => {
const key = this.#load(name, 'encrypt');
const r = this.#decryptWith(key, ciphertext, normAad(aad));
return { value: r, version: r.version };
});
}
// Reincapsuleaza la ultima versiune. Textul clar nu iese din proces: raspunsul are numai
// textul cifrat nou.
rewrap(name, ciphertext, aad) {
return this.#run('rewrap', name, () => {
const key = this.#load(name, 'encrypt');
const aadB = normAad(aad);
const { plaintext } = this.#decryptWith(key, ciphertext, aadB);
try {
const r = this.#encryptWith(key, plaintext, aadB);
return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version };
} finally {
plaintext.fill(0);
}
});
}
datakey(name, { aad, bits = 256, includePlaintext = true } = {}) {
return this.#run('datakey', name, () => {
const key = this.#load(name, 'encrypt');
if (![128, 256, 512].includes(bits)) throw new KmsError('INVALID_BITS', 'bits must be 128, 256 or 512');
if (typeof includePlaintext !== 'boolean') throw new KmsError('INVALID_INPUT', 'includePlaintext must be a boolean');
const dk = crypto.randomBytes(bits / 8);
try {
const r = this.#encryptWith(key, dk, normAad(aad));
const value = { ciphertext: r.ciphertext, version: r.version };
if (includePlaintext) value.plaintext = dk.toString('base64');
return { value, version: r.version };
} finally {
dk.fill(0);
}
});
}
sign(name, message) {
return this.#run('sign', name, () => {
const key = this.#load(name, 'sign');
const msg = toBytes(message, 'message', MAX_MESSAGE);
const ver = key.latest_version;
const pk = this.#publicKeys(key, ver);
const sk = this.#privateKeys(key, ver);
const m = sigMessage(pk.fp, ver, msg);
const edSig = crypto.sign(null, m, sk.a);
const mlSig = crypto.sign(null, m, { key: sk.b, context: utf8(LABELS.sigCtx) });
if (edSig.length !== SZ.ed || mlSig.length !== SZ.mlSig) throw new KmsError('INTERNAL', 'unexpected signature length');
const body = Buffer.concat([MAGIC_S, Buffer.from([KIND_S]), u32(ver), pk.fp, edSig, mlSig]);
return { value: { signature: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver }, version: ver };
});
}
// Intoarce { valid, reason, version, classical, post_quantum }. valid cere AMBELE semnaturi.
verify(name, message, signature) {
return this.#run('verify', name, () => {
const key = this.#load(name, 'sign');
const msg = toBytes(message, 'message', MAX_MESSAGE);
let r;
try {
r = this.#verifyWith(key, msg, signature);
} catch (e) {
if (e instanceof KmsError && VERIFY_REFUSALS.has(e.code)) {
r = { valid: false, reason: e.code, version: e.version ?? null, classical: false, post_quantum: false };
} else {
throw e;
}
}
return { value: r, version: r.version, ok: r.valid, reason: r.reason };
});
}
}

333
pq-kms/server.mjs Normal file
View File

@ -0,0 +1,333 @@
// server.mjs - serverul HTTP al Aere PQ KMS (node:http, fara dependinte).
//
// Mediu:
// AERE_KMS_ROOT_KEY 64 de caractere hexa (32 de octeti), SAU:
// AERE_KMS_ROOT_HSM calea radacinii SIGILATE de un HSM (hsm-radacina.mjs), deschisa la pornire cu PIN-ul din AERE_HSM_PIN;
// exact una din cele doua; fara niciuna refuza pornirea, cu amandoua refuza (ROOT_KEY_AMBIGUOUS)
// AERE_KMS_TOKEN obligatoriu, cel putin 32 de caractere; se cere ca "Authorization: Bearer <token>"
// AERE_KMS_HOST implicit 127.0.0.1
// AERE_KMS_PORT implicit 8420
// AERE_KMS_MAX_BODY implicit 65536 (octeti)
// AERE_KMS_DATA_DIR implicit ./data langa acest fisier
//
// Nicio valoare din mediu nu se tipareste. Raspunsurile de eroare sunt { error: COD, message }.
import http from 'node:http';
import crypto from 'node:crypto';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { openKms, parseRootKey, KmsError } from './kms.mjs';
import { radacinaDinHsm } from './hsm-radacina.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STATUS = {
NOT_FOUND: 404,
KEY_NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
KEY_EXISTS: 409,
KEY_NOT_EXPORTABLE: 403,
UNAUTHENTICATED: 401,
INVALID_TOKEN: 401,
BODY_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
INTERNAL: 500,
AUDIT_WRITE_FAILED: 500,
SEAL_AUTH_FAILED: 500,
KEY_FILE_TAMPERED: 500,
KMS_CLOSED: 503,
};
class HttpError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
export function configFromEnv(env, optiuniHsm = {}) {
const token = String(env.AERE_KMS_TOKEN ?? '').trim();
if (token === '') throw new KmsError('TOKEN_MISSING', 'AERE_KMS_TOKEN is not set; refusing to start');
if (token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', `AERE_KMS_TOKEN must be at least 32 characters; the value given has ${token.length}`);
if (!/^[\x21-\x7e]+$/.test(token)) throw new KmsError('TOKEN_INVALID', 'AERE_KMS_TOKEN must be printable ASCII without spaces');
// Validam cheia radacina aici, ca serverul sa nu porneasca deloc fara ea. Din 2026-09-28 poate veni si SIGILATA de un HSM.
const inClar = String(env.AERE_KMS_ROOT_KEY ?? '').trim() !== '', dinHsm = String(env.AERE_KMS_ROOT_HSM ?? '').trim() !== '';
if (inClar && dinHsm) throw new KmsError('ROOT_KEY_AMBIGUOUS', 'set AERE_KMS_ROOT_KEY or AERE_KMS_ROOT_HSM, not both');
const rootHex = dinHsm ? radacinaDinHsm(env, optiuniHsm) : env.AERE_KMS_ROOT_KEY;
const root = parseRootKey(rootHex);
const sameAsRoot = token.toLowerCase() === root.toString('hex');
root.fill(0);
if (sameAsRoot) throw new KmsError('TOKEN_EQUALS_ROOT_KEY', 'AERE_KMS_TOKEN must not be the root key');
const host = String(env.AERE_KMS_HOST ?? '').trim() || '127.0.0.1';
const portRaw = String(env.AERE_KMS_PORT ?? '').trim() || '8420';
const port = Number(portRaw);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new KmsError('INVALID_PORT', 'AERE_KMS_PORT must be an integer between 0 and 65535');
const maxRaw = String(env.AERE_KMS_MAX_BODY ?? '').trim() || '65536';
const maxBody = Number(maxRaw);
// Sub 8192 nu incape nici o cerere de verificare (semnatura hibrida are 4531 de caractere base64).
if (!Number.isInteger(maxBody) || maxBody < 8192 || maxBody > 16 * 1024 * 1024) {
throw new KmsError('INVALID_MAX_BODY', 'AERE_KMS_MAX_BODY must be an integer between 8192 and 16777216');
}
const dataDir = String(env.AERE_KMS_DATA_DIR ?? '').trim() || path.join(HERE, 'data');
return { token, rootKey: rootHex, rootSource: dinHsm ? 'hsm' : 'env', host, port, maxBody, dataDir };
}
function tooBig(n, maxBody) {
return n > maxBody;
}
function authCheck(req, tokenHash) {
const header = req.headers['authorization'];
if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED';
const m = /^Bearer ([\x21-\x7e]+)$/.exec(header);
if (!m) return 'INVALID_TOKEN';
const got = crypto.createHash('sha256').update(m[1], 'utf8').digest();
if (!crypto.timingSafeEqual(got, tokenHash)) return 'INVALID_TOKEN';
return null;
}
// Arunca restul corpului fara sa-l tina in memorie; peste plafon inchide conexiunea.
function discard(req, cap) {
let n = 0;
req.on('data', (c) => {
n += c.length;
if (n > cap) req.destroy();
});
req.on('error', () => {});
req.resume();
}
function readBody(req, maxBody) {
return new Promise((resolve, reject) => {
const declared = req.headers['content-length'];
if (declared !== undefined) {
const n = Number(declared);
if (!Number.isSafeInteger(n) || n < 0) return reject(new HttpError(400, 'INVALID_CONTENT_LENGTH', 'invalid Content-Length'));
if (tooBig(n, maxBody)) return reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`));
}
const chunks = [];
let size = 0;
let done = false;
req.on('data', (c) => {
if (done) return;
size += c.length;
if (tooBig(size, maxBody)) {
done = true;
chunks.length = 0;
reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`));
return;
}
chunks.push(c);
});
req.on('end', () => {
if (!done) {
done = true;
resolve(Buffer.concat(chunks));
}
});
req.on('error', () => {
if (!done) {
done = true;
reject(new HttpError(400, 'BAD_REQUEST', 'the request body could not be read'));
}
});
});
}
function send(res, status, obj, close = false) {
const body = JSON.stringify(obj);
const headers = {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
};
if (close) headers.connection = 'close';
res.writeHead(status, headers);
res.end(body);
}
function b64Field(obj, field, { optional = false } = {}) {
const v = obj[field];
if (v === undefined || v === null) {
if (optional) return undefined;
throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required`);
}
if (typeof v !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(v) || v.length % 4 !== 0) {
throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be standard base64`);
}
const b = Buffer.from(v, 'base64');
if (b.toString('base64') !== v) throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be canonical base64`);
return b;
}
function strField(obj, field) {
const v = obj[field];
if (typeof v !== 'string') throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required and must be a string`);
return v;
}
export function createServer({ kms, token, maxBody = 65536 }) {
if (typeof token !== 'string' || token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', 'token must be at least 32 characters');
const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest();
const drainCap = maxBody * 4 + 1024 * 1024;
async function handle(req, res) {
let url;
try {
url = new URL(req.url, 'http://localhost');
} catch {
discard(req, drainCap);
return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid request URL' }, true);
}
const method = req.method;
const parts = url.pathname.split('/').filter((p) => p !== '');
if (method === 'GET' && url.pathname === '/v1/health') {
discard(req, drainCap);
return send(res, 200, { ok: true });
}
// Autentificarea se face INAINTE de a citi corpul.
const authErr = authCheck(req, tokenHash);
if (authErr) {
discard(req, drainCap);
return send(res, 401, { error: authErr, message: authErr === 'UNAUTHENTICATED' ? 'missing bearer token' : 'invalid bearer token' }, true);
}
let body = null;
if (method === 'POST') {
const ctype = String(req.headers['content-type'] ?? '');
let raw;
try {
raw = await readBody(req, maxBody);
} catch (e) {
discard(req, drainCap);
throw e;
}
if (raw.length > 0 && ctype !== '' && !/^application\/json\b/i.test(ctype)) {
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'request body must be application/json');
}
if (raw.length === 0) body = {};
else {
try {
body = JSON.parse(raw.toString('utf8'));
} catch {
throw new HttpError(400, 'INVALID_JSON', 'request body is not valid JSON');
}
}
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'INVALID_JSON', 'request body must be a JSON object');
} else {
discard(req, drainCap);
}
const name = parts[2] !== undefined ? decodeURIComponent(parts[2]) : undefined;
if (parts[0] !== 'v1') throw new HttpError(404, 'NOT_FOUND', 'no such route');
// /v1/keys
if (parts[1] === 'keys') {
if (parts.length === 2 && method === 'GET') return send(res, 200, { keys: kms.listKeys() });
if (parts.length === 3 && method === 'POST') {
const exportable = body.exportable === undefined ? false : body.exportable;
return send(res, 200, kms.createKey(name, { type: body.type, exportable }));
}
if (parts.length === 3 && method === 'GET') return send(res, 200, kms.getKey(name));
if (parts.length === 4 && parts[3] === 'rotate' && method === 'POST') return send(res, 200, kms.rotate(name));
if (parts.length === 4 && parts[3] === 'config' && method === 'POST') {
return send(res, 200, kms.setMinDecryptionVersion(name, body.min_decryption_version));
}
if ((parts.length === 4 || parts.length === 5) && parts[3] === 'export' && method === 'GET') {
let ver;
if (parts.length === 5) {
if (!/^[1-9][0-9]{0,8}$/.test(parts[4])) throw new HttpError(400, 'INVALID_VERSION', 'version must be a positive integer');
ver = Number(parts[4]);
}
return send(res, 200, kms.exportKey(name, ver));
}
}
if (parts.length === 3 && method === 'POST') {
switch (parts[1]) {
case 'encrypt': {
const r = kms.encrypt(name, b64Field(body, 'plaintext'), b64Field(body, 'aad', { optional: true }));
return send(res, 200, r);
}
case 'decrypt': {
const r = kms.decrypt(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true }));
const out = { plaintext: r.plaintext.toString('base64'), version: r.version };
r.plaintext.fill(0);
return send(res, 200, out);
}
case 'rewrap':
return send(res, 200, kms.rewrap(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true })));
case 'datakey': {
const includePlaintext = body.include_plaintext === undefined ? true : body.include_plaintext;
const bits = body.bits === undefined ? 256 : body.bits;
return send(res, 200, kms.datakey(name, { aad: b64Field(body, 'aad', { optional: true }), bits, includePlaintext }));
}
case 'sign':
return send(res, 200, kms.sign(name, b64Field(body, 'message')));
case 'verify':
return send(res, 200, kms.verify(name, b64Field(body, 'message'), strField(body, 'signature')));
default:
break;
}
}
if (parts.length === 3 && parts[1] === 'audit' && parts[2] === 'verify' && method === 'GET') {
return send(res, 200, kms.verifyAudit());
}
throw new HttpError(404, 'NOT_FOUND', 'no such route');
}
const server = http.createServer((req, res) => {
handle(req, res).catch((e) => {
if (res.headersSent) {
res.destroy();
return;
}
if (e instanceof HttpError) return send(res, e.status, { error: e.code, message: e.message }, e.status === 413);
if (e instanceof KmsError) return send(res, STATUS[e.code] ?? 400, { error: e.code, message: e.message });
if (e instanceof URIError) return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid percent-encoding in path' });
return send(res, 500, { error: 'INTERNAL', message: 'internal error' });
});
});
server.headersTimeout = 10_000;
server.requestTimeout = 30_000;
return server;
}
async function main() {
let cfg;
let kms;
try {
cfg = configFromEnv(process.env);
kms = openKms({ dataDir: cfg.dataDir, rootKey: cfg.rootKey });
} catch (e) {
const code = e instanceof KmsError ? e.code : 'INTERNAL';
const msg = e instanceof KmsError ? e.message : 'internal error';
process.stderr.write(`aere-pq-kms: refusing to start: ${code}: ${msg}\n`);
process.exitCode = 1;
return;
}
const server = createServer({ kms, token: cfg.token, maxBody: cfg.maxBody });
server.on('error', (e) => {
process.stderr.write(`aere-pq-kms: server error: ${e.code ?? 'ERROR'}\n`);
process.exitCode = 1;
});
server.listen(cfg.port, cfg.host, () => {
const a = server.address();
process.stdout.write(`aere-pq-kms: listening on ${a.address}:${a.port}\n`);
});
const stop = () => {
server.close(() => kms.close());
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}

View File

@ -0,0 +1,58 @@
// test/control-negativ-hsm-incredere.mjs (2026-09-29): controlul negativ al probei H1 (proba-hsm-incredere.mjs). Trei stari pe caz:
// PRINS (proba a rulat pana la capat si a iesit rosie pe verificarea numita), SCAPAT, STRICAT (copia nu s-a incarcat / proba nu a rulat).
// V0 modulul de DINAINTE de reparatie (HEAD-ul lui git la momentul scrierii: `git show <rev>:.../hsm-radacina.mjs`) -> rosu pe H1
// P1 verificarea bibliotecii scoasa cu o conditie falsa la rulare -> rosu pe biblioteca straina
// P2 verificarea variabilei PIN-ului scoasa cu o conditie falsa la rulare -> rosu pe jetonul API ca PIN
// Copiile stau LANGA original (./kms.mjs trebuie sa se rezolve) si se sterg la sfarsit; originalul trebuie sa ramana octet cu octet.
// node test/control-negativ-hsm-incredere.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 } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const SRC = path.join(AICI, '..', 'hsm-radacina.mjs');
const PROBA = path.join(AICI, 'proba-hsm-incredere.mjs');
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
const inainte = sha(SRC);
const REV_VECHE = process.env.AERE_HSM_REV_VECHE || '7f2e7182';
let prinse = 0, stricate = 0, total = 0;
function caz(id, text, tinta) {
total++;
const copie = path.join(AICI, '..', `.plantat-hsm-${id}.mjs`);
try {
fs.writeFileSync(copie, text);
const r = spawnSync(process.execPath, [PROBA], { encoding: 'utf8', env: { ...process.env, AERE_HSM_MODUL_SUB_PROBA: copie } });
const out = (r.stdout || '') + (r.stderr || '');
if (!/increderea in fisierul sigilat \(H1\): \d+\/\d+/.test(out)) { stricate++; console.log(` STRICAT ${id}: proba nu a ajuns la capat (${out.trim().split('\n').pop().slice(0, 120)})`); return; }
if (!/\[OK \] CONTROL POZITIV/.test(out)) { stricate++; console.log(` STRICAT ${id}: controlul pozitiv al probei nu a trecut pe copie`); return; }
const rosii = out.split('\n').filter((l) => l.includes('[RAU ]'));
if (r.status === 1 && rosii.some((l) => l.includes(tinta))) { prinse++; console.log(` PRINS ${id}: ${rosii.length} verificari rosii, intre ele "${tinta}"`); }
else console.log(` SCAPAT ${id}: cod ${r.status}, "${tinta}" nu e rosie`);
} finally { fs.rmSync(copie, { force: true }); }
}
const inlocuieste = (t, a, b) => { if (t.split(a).length !== 2) return null; return t.replace(a, b); };
// V0: codul vechi, din git (octetii comisi, fara conversia autocrlf)
// In depozitul public (alt istoric) codul de dinainte nu exista: V0 se sare, spus, si nu se numara; in depozitul de lucru trebuie sa ruleze.
const CALE_VECHE = `${REV_VECHE}:cloud-gateway/pq-kms/hsm-radacina.mjs`;
const exista = spawnSync('git', ['cat-file', '-e', CALE_VECHE], { cwd: path.join(AICI, '..'), encoding: 'utf8' }).status === 0;
if (!exista) console.log(` SARIT V0: codul de dinainte de reparatie (${REV_VECHE}) nu e in istoricul acestui depozit; NEMASURAT aici`);
else {
const g = spawnSync('git', ['-c', 'core.autocrlf=false', 'show', CALE_VECHE], { cwd: path.join(AICI, '..'), encoding: 'utf8' });
if (g.status !== 0 || !g.stdout.includes('export function deschideRadacina')) { stricate++; total++; console.log(' STRICAT V0: nu citesc modulul vechi din git (' + REV_VECHE + ')'); }
else caz('V0', g.stdout, 'biblioteca din fisier, alta decat AERE_HSM_MODULE');
}
const nou = fs.readFileSync(SRC, 'utf8');
const p1 = inlocuieste(nou, "if (sig.modul !== permis) throw", "if (sig.modul !== permis && process.env.AERE_PLANTA_NICIODATA === 'da') throw");
if (!p1) { stricate++; total++; console.log(' STRICAT P1: ancora nu apare exact o data'); } else caz('P1', p1, 'biblioteca din fisier, alta decat AERE_HSM_MODULE');
const p2 = inlocuieste(nou, "if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV) throw", "if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV && process.env.AERE_PLANTA_NICIODATA === 'da') throw");
if (!p2) { stricate++; total++; console.log(' STRICAT P2: ancora nu apare exact o data'); } else caz('P2', p2, 'alta variabila drept PIN');
const ramase = fs.readdirSync(path.join(AICI, '..')).filter((f) => f.startsWith('.plantat-hsm-'));
if (sha(SRC) !== inainte || ramase.length) { stricate++; console.log(' STRICAT originalul s-a schimbat sau au ramas copii: ' + ramase.join(',')); }
console.log(`\ncontrolul negativ H1: prinse ${prinse}/${total}, stricate ${stricate}`);
process.exitCode = stricate ? 2 : prinse === total ? 0 : 1;

View File

@ -0,0 +1,423 @@
// test/control-negativ.mjs - controlul negativ al probei Aere PQ KMS.
//
// O proba care nu poate iesi rosie nu masoara nimic. Aici se planteaza, intr-o COPIE a codului,
// cate un defect real, si se cere ca proba sa iasa ROSIE pe exact probele numite, pentru
// MOTIVUL numit (un fragment din mesajul afirmatiei care a cazut), nu doar "ceva a picat".
//
// Reguli, platite in alte parti ale casei:
// - verdictul se citeste din JSON-ul probei (AERE_KMS_PROBA_JSON), nu din codul de iesire;
// o proba care nu a ajuns la capat sau a rulat alt numar de probe e STRICAT, nu ROSU;
// - copia se reface pentru FIECARE plantare si se compara sha256 cu originalul inainte de a
// planta (altfel s-ar masura plantarea de dinainte);
// - ancora unei plantari trebuie sa apara EXACT o data; o plantare care nu se poate pune e un
// ESEC al controlului, nu o trecere;
// - plantarile se fac cu o conditie falsa la RULARE sau cu o inlocuire care ramane cod valid,
// ca rosul sa vina din proba, nu din incarcarea modulului;
// - la sfarsit originalul trebuie sa fie identic octet cu octet (sha256).
//
// Iesire: 0 numai daca proba e VERDE pe copia neatinsa, fiecare plantare e ROSIE pe probele
// numite si originalul e neatins; altfel 1.
//
// --autoproba: controlul controlului. Ruleaza momeli (ancora inexistenta, plantare fara efect,
// motiv gresit, proba inexistenta, modul care nu se incarca) si cere ca fiecare sa primeasca
// verdictul ei de ESEC. Un control care nu a fost vazut esuand nu se poate crede.
import crypto from 'node:crypto';
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 HERE = path.dirname(fileURLToPath(import.meta.url));
const RADACINA = path.resolve(HERE, '..');
// 2026-09-28: server.mjs importa si hsm-radacina.mjs (radacina sigilata de HSM); fara el copia nu mai pornea si linia de baza iesea STRICATA
const FISIERE = ['kms.mjs', 'server.mjs', 'hsm-radacina.mjs', 'test/proba.mjs'];
const NICIODATA = "process.env.AERE_KMS_PLANTA_NICIODATA === 'da'";
// Fiecare plantare: fisierul, ancora (exact o aparitie), inlocuirea, si probele care TREBUIE sa
// iasa rosii, fiecare cu un fragment din motivul pe care trebuie sa cada.
const PLANTARI = [
{
id: 'P16',
defect: 'A4: mutatia ramane pe disc cand randul de audit nu se poate scrie (rollback-ul nu se mai face)',
fisier: 'kms.mjs',
ancora: ' if (!changed) return;',
inlocuire: " if (!changed || !(process.env.AERE_KMS_PLANTA_NICIODATA === 'da')) return;",
rosii: [
['audit: o mutatie al carei rand de audit nu se poate scrie NU ramane pe disc (rotire, minim, creare), iar lantul ramane valid', 'rotirea a ramas pe disc fara rand de audit'],
],
},
{
id: 'P01',
defect: 'KDF fara secretul ML-KEM (IKM = numai X25519)',
fisier: 'kms.mjs',
ancora: 'const ikm = Buffer.concat([ssK, ssX]);',
inlocuire: 'const ikm = Buffer.concat([ssX]);',
rosii: [
['criptare: atacatorul cu doar secretul X25519 nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul X25519'],
['interop: plicul serviciului decapsulat si decriptat manual cu node:crypto', 'commit'],
['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-x: trebuia refuzat'],
],
},
{
id: 'P02',
defect: 'KDF fara secretul X25519 (IKM = numai ML-KEM)',
fisier: 'kms.mjs',
ancora: 'const ikm = Buffer.concat([ssK, ssX]);',
inlocuire: 'const ikm = Buffer.concat([ssK]);',
rosii: [
['criptare: atacatorul cu doar secretul ML-KEM nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul ML-KEM'],
['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-k: trebuia refuzat'],
],
},
{
id: 'P03',
defect: 'verificarea accepta o singura semnatura (SAU in loc de SI)',
fisier: 'kms.mjs',
ancora: 'const valid = edOk && pqOk;',
inlocuire: 'const valid = edOk || pqOk;',
rosii: [
['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false'],
['semnare: jumatatea ML-DSA-65 stricata e refuzata cu PQ_SIGNATURE_INVALID', 'valid: asteptat false'],
['semnare: jumatati amestecate din doua semnaturi valide sunt refuzate', 'amestec peste "unu": asteptat false'],
],
},
{
id: 'P04',
defect: 'AAD ignorat (inlocuit cu sirul gol)',
fisier: 'kms.mjs',
ancora: "return toBytes(aad, 'aad', MAX_AAD);",
inlocuire: 'return Buffer.alloc(0);',
rosii: [
['criptare: alt aad refuzat cu AAD_MISMATCH', 'alt aad: trebuia refuzat cu AAD_MISMATCH'],
['datakey: cheia clara si cea invelita corespund; forma doar-invelita nu intoarce cheia clara', 'cheia de date legata de aad'],
['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'rewrap cu alt aad'],
],
},
{
id: 'P05',
defect: 'min_decryption_version ignorat',
fisier: 'kms.mjs',
ancora: 'if (ver < key.min_decryption_version) throw',
inlocuire: `if (ver < key.min_decryption_version && ${NICIODATA}) throw`,
rosii: [
['rotire: sub min_decryption_version decriptarea e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: trebuia refuzat'],
['rotire: sub min_decryption_version verificarea semnaturii e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: asteptat false'],
['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'sub minim prin HTTP'],
],
},
{
id: 'P06',
defect: 'jurnalul scris fara legatura cu randul anterior (prev constant)',
fisier: 'kms.mjs',
ancora: 'prev: this.#head.mac,',
inlocuire: 'prev: ZERO_MAC,',
rosii: [
['audit: jurnalul neatins se verifica OK, un rand pe operatie, si refuzurile au motivul lor', 'jurnal neatins'],
['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'randul: asteptat 4'],
],
},
{
id: 'P07',
defect: 'verificatorul jurnalului nu verifica legatura (seq/prev)',
fisier: 'kms.mjs',
ancora: 'if (row.seq !== seq + 1 || row.prev !== prev)',
inlocuire: `if ((row.seq !== seq + 1 || row.prev !== prev) && ${NICIODATA})`,
rosii: [
['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'rand sters: asteptat false'],
['audit: randuri reordonate detectate cu AUDIT_CHAIN_BROKEN', 'reordonare: asteptat false'],
['audit: pornirea refuza un jurnal stricat cu AUDIT_CHAIN_INVALID', 'trebuia refuzat cu AUDIT_CHAIN_INVALID'],
],
},
{
id: 'P08',
defect: 'verificatorul jurnalului nu verifica mac-ul randului',
fisier: 'kms.mjs',
ancora: "if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1);",
inlocuire: `if (!macOk && ${NICIODATA}) return fail('AUDIT_ROW_MODIFIED', i + 1);`,
rosii: [['audit: rand schimbat detectat cu AUDIT_ROW_MODIFIED', 'rand schimbat: asteptat false']],
},
{
id: 'P09',
defect: 'cheia privata scrisa si necifrata langa sigiliu',
fisier: 'kms.mjs',
ancora: 'private_sealed: sealed,',
inlocuire: "private_sealed: sealed, private_debug: Buffer.from(privJson).toString('base64'),",
rosii: [['stocare: materialul privat nu apare in clar pe disc (cu controlul pozitiv al scanerului)', 'material privat gasit in clar']],
},
{
id: 'P10',
defect: 'integritatea fisierului de cheie neverificata',
fisier: 'kms.mjs',
ancora: "if (!macOk) throw new KmsError('KEY_FILE_TAMPERED'",
inlocuire: `if (!macOk && ${NICIODATA}) throw new KmsError('KEY_FILE_TAMPERED'`,
rosii: [
['stocare: min_decryption_version coborat pe disc refuzat cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'],
['stocare: cheie publica inlocuita pe disc refuzata cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'],
],
},
{
id: 'P11',
defect: 'fara cheie radacina se genereaza una in tacere',
fisier: 'kms.mjs',
ancora: "if (s === '') throw new KmsError('ROOT_KEY_MISSING',",
inlocuire: "if (s === '') return crypto.randomBytes(32); if (s === '') throw new KmsError('ROOT_KEY_MISSING',",
rosii: [
['radacina: lipsa cheii refuza pornirea cu ROOT_KEY_MISSING si nu creeaza nimic pe disc', 'trebuia refuzat cu ROOT_KEY_MISSING'],
['server: configurarea implicita asculta pe 127.0.0.1 si cere token si cheie radacina', 'fara cheie radacina: trebuia refuzat'],
['server: pornit din linia de comanda refuza fara AERE_KMS_ROOT_KEY si nu tipareste tokenul', 'codul de iesire'],
],
},
{
id: 'P12',
defect: 'amprenta cheii/versiunii din plic neverificata',
fisier: 'kms.mjs',
ancora: 'if (!fp.equals(fpExpected)) {',
inlocuire: `if (!fp.equals(fpExpected) && ${NICIODATA}) {`,
rosii: [
['criptare: alta cheie refuzata cu KEY_MISMATCH', 'motiv asteptat KEY_MISMATCH'],
['semnare: alta cheie refuzata cu KEY_MISMATCH, semnatura malformata cu MALFORMED_SIGNATURE', 'motivul: asteptat "KEY_MISMATCH"'],
],
},
{
id: 'P13',
defect: 'rewrap intoarce si textul clar',
fisier: 'kms.mjs',
ancora: 'return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version };',
inlocuire: "return { value: { ciphertext: r.ciphertext, version: r.version, plaintext: plaintext.toString('base64') }, version: r.version };",
rosii: [
['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'campurile raspunsului'],
['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'rewrap nu intoarce text clar'],
],
},
{
id: 'P14',
defect: 'serverul lasa sa treaca o cerere fara Authorization',
fisier: 'server.mjs',
ancora: "if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED';",
inlocuire: "if (typeof header !== 'string' || header === '') return null;",
rosii: [
['server: fara Authorization raspunde 401 UNAUTHENTICATED', 'fara token: asteptat 401'],
['server: pornit din linia de comanda asculta pe 127.0.0.1 si raspunde', 'cerere neautentificata'],
],
},
{
id: 'P15',
defect: 'serverul ignora limita de marime a cererii',
fisier: 'server.mjs',
ancora: 'return n > maxBody;',
inlocuire: 'return false;',
rosii: [
['server: cerere peste limita cu Content-Length raspunde 413 BODY_TOO_LARGE', 'peste limita: asteptat 413'],
['server: cerere peste limita fara Content-Length (chunked) raspunde 413 BODY_TOO_LARGE', 'peste limita, chunked: asteptat 413'],
],
},
];
// Momelile pentru --autoproba: controlul insusi trebuie sa poata iesi rosu. Fiecare momeala e o
// plantare stricata dinadins si are verdictul pe care controlul TREBUIE sa i-l dea.
const MOMELI = [
{
id: 'M1',
defect: 'ancora care nu exista in cod',
fisier: 'kms.mjs',
ancora: 'ACEASTA ANCORA NU EXISTA IN COD',
inlocuire: 'x',
rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']],
asteptat: 'ESEC CONTROL',
},
{
id: 'M2',
defect: 'plantare fara efect (un comentariu)',
fisier: 'kms.mjs',
ancora: "export const PREFIX = 'aerekms';",
inlocuire: "export const PREFIX = 'aerekms'; // momeala fara efect",
rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']],
asteptat: 'VERDE (NEPRINS)',
},
{
id: 'M3',
defect: 'defect real, dar motivul cerut nu e cel pe care cade proba',
fisier: 'kms.mjs',
ancora: 'const valid = edOk && pqOk;',
inlocuire: 'const valid = edOk || pqOk;',
rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'un motiv care nu apare niciodata']],
asteptat: 'ROSU GRESIT',
},
{
id: 'M4',
defect: 'proba numita nu exista',
fisier: 'kms.mjs',
ancora: 'const valid = edOk && pqOk;',
inlocuire: 'const valid = edOk || pqOk;',
rosii: [['o proba care nu exista', 'x']],
asteptat: 'ESEC CONTROL',
},
{
id: 'M5',
defect: 'plantare care rupe incarcarea modulului',
fisier: 'kms.mjs',
ancora: 'const valid = edOk && pqOk;',
inlocuire: 'const valid = edOk && ;',
rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false']],
asteptat: 'STRICAT',
},
];
// ---------------------------------------------------------------------------------------------
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const amprente = () => Object.fromEntries(FISIERE.map((f) => [f, sha(path.join(RADACINA, f))]));
function pregatesteCopie(dest) {
fs.rmSync(dest, { recursive: true, force: true });
for (const f of FISIERE) {
const d = path.join(dest, f);
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(path.join(RADACINA, f), d);
}
for (const f of FISIERE) {
if (sha(path.join(dest, f)) !== sha(path.join(RADACINA, f))) throw new Error(`copia lui ${f} difera de original inainte de plantare`);
}
}
function planteaza(dest, p) {
const fp = path.join(dest, p.fisier);
const text = fs.readFileSync(fp, 'utf8');
const bucati = text.split(p.ancora);
if (bucati.length !== 2) return { ok: false, motiv: `ancora apare de ${bucati.length - 1} ori in ${p.fisier} (trebuie exact o data)` };
const nou = bucati.join(p.inlocuire);
fs.writeFileSync(fp, nou);
if (sha(fp) === sha(path.join(RADACINA, p.fisier))) return { ok: false, motiv: 'fisierul plantat e identic cu originalul' };
return { ok: true };
}
function envCurat() {
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('AERE_KMS_')));
}
function ruleaza(dest) {
const out = path.join(dest, 'rezultat-proba.json');
fs.rmSync(out, { force: true });
const t0 = Date.now();
const r = spawnSync(process.execPath, [path.join(dest, 'test', 'proba.mjs')], {
cwd: dest,
env: { ...envCurat(), AERE_KMS_PROBA_JSON: out },
encoding: 'utf8',
timeout: 300000,
maxBuffer: 16 * 1024 * 1024,
});
let json = null;
try {
json = JSON.parse(fs.readFileSync(out, 'utf8'));
} catch {
json = null;
}
return { json, status: r.status, signal: r.signal, ms: Date.now() - t0, stderr: String(r.stderr || '').slice(-600) };
}
function main() {
const autoproba = process.argv.includes('--autoproba');
const PL = autoproba ? MOMELI : PLANTARI;
if (autoproba) console.log('AUTOPROBA: momelile de mai jos TREBUIE sa primeasca verdictul lor de esec\n');
const inainte = amprente();
const lucru = fs.mkdtempSync(path.join(os.tmpdir(), 'aerekms-control-'));
const dest = path.join(lucru, 'copie');
const linii = [];
let esecuri = 0;
try {
// 1. linia de baza: copia neatinsa trebuie sa fie VERDE
pregatesteCopie(dest);
const baza = ruleaza(dest);
if (!baza.json || baza.json.completed !== true) {
console.log(`LINIA DE BAZA STRICATA: proba nu a ajuns la capat (cod ${baza.status}, semnal ${baza.signal})\n${baza.stderr}`);
return 1;
}
const numeBaza = new Set(baza.json.results.map((r) => r.nume));
if (baza.json.failed.length !== 0) {
console.log(`LINIA DE BAZA ROSIE: ${baza.json.failed.length} probe pica pe codul neatins:\n ${baza.json.failed.join('\n ')}`);
return 1;
}
console.log(`linia de baza: VERDE, ${baza.json.passed}/${baza.json.total} probe, ${baza.ms} ms (node ${baza.json.node}, openssl ${baza.json.openssl})\n`);
// 2. fiecare plantare pe o copie proaspata
for (const p of PL) {
pregatesteCopie(dest);
const numite = p.rosii.map(([n]) => n);
const lipsa = numite.filter((n) => !numeBaza.has(n));
if (lipsa.length) {
esecuri++;
linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `proba numita nu exista in proba: ${lipsa.join(' | ')}` });
continue;
}
const pl = planteaza(dest, p);
if (!pl.ok) {
esecuri++;
linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `plantarea nu s-a putut pune: ${pl.motiv}` });
continue;
}
const r = ruleaza(dest);
if (!r.json || r.json.completed !== true || r.json.total !== baza.json.total) {
esecuri++;
linii.push({ p, verdict: 'STRICAT', detaliu: `proba nu a rulat intreaga (cod ${r.status}, probe ${r.json ? r.json.total : '-'}/${baza.json.total}); ${r.stderr.split('\n').slice(-3).join(' ')}` });
continue;
}
const dupaNume = new Map(r.json.results.map((x) => [x.nume, x]));
const probleme = [];
let rosiiNumite = 0;
for (const [nume, motiv] of p.rosii) {
const x = dupaNume.get(nume);
if (x.ok) probleme.push(`VERDE: ${nume}`);
else if (!String(x.motiv).includes(motiv)) probleme.push(`ROSU DIN ALT MOTIV: ${nume} -> "${x.motiv.slice(0, 160)}" (cerut: "${motiv}")`);
else rosiiNumite++;
}
const inPlus = r.json.failed.filter((n) => !numite.includes(n));
if (probleme.length === 0) {
linii.push({ p, verdict: 'ROSU', detaliu: `${rosiiNumite}/${numite.length} numite rosii pe motivul cerut; alte ${inPlus.length} rosii in plus`, inPlus, ms: r.ms });
} else {
esecuri++;
linii.push({ p, verdict: r.json.failed.length === 0 ? 'VERDE (NEPRINS)' : 'ROSU GRESIT', detaliu: probleme.join('\n '), inPlus, ms: r.ms });
}
}
} finally {
fs.rmSync(lucru, { recursive: true, force: true });
}
for (const l of linii) {
console.log(`${l.verdict.padEnd(15)} ${l.p.id} ${l.p.defect}${l.ms ? ` (${l.ms} ms)` : ''}\n ${l.detaliu}`);
if (l.inPlus && l.inPlus.length) console.log(` in plus: ${l.inPlus.join(' | ')}`);
}
// 3. originalul neatins
const dupa = amprente();
const schimbate = FISIERE.filter((f) => inainte[f] !== dupa[f]);
if (schimbate.length) {
esecuri++;
console.log(`\nORIGINALUL S-A SCHIMBAT: ${schimbate.join(', ')}`);
} else {
console.log(`\noriginal neatins (sha256): ${FISIERE.map((f) => `${f}=${dupa[f].slice(0, 12)}`).join(' ')}`);
}
if (autoproba) {
// Aici esecurile sunt ceruse: fiecare momeala trebuie sa primeasca exact verdictul ei.
let potrivite = 0;
for (const l of linii) {
const ok = l.verdict === l.p.asteptat;
if (ok) potrivite++;
console.log(`${ok ? 'CORECT ' : 'GRESIT '} ${l.p.id}: controlul a spus "${l.verdict}", trebuia "${l.p.asteptat}"`);
}
const originalOk = schimbate.length === 0;
console.log(`\nautoproba controlului: ${potrivite}/${MOMELI.length} momeli cu verdictul cerut; original ${originalOk ? 'neatins' : 'SCHIMBAT'}`);
return potrivite === MOMELI.length && linii.length === MOMELI.length && originalOk ? 0 : 1;
}
const rosii = linii.filter((l) => l.verdict === 'ROSU').length;
console.log(`\ncontrol negativ: ${rosii}/${PLANTARI.length} plantari prinse pe probele numite; ${esecuri} esecuri ale controlului`);
return esecuri === 0 && rosii === PLANTARI.length ? 0 : 1;
}
try {
process.exitCode = main();
} catch (e) {
console.log(`controlul negativ a cazut: ${(e && e.stack) || e}`);
process.exitCode = 2;
}

View File

@ -0,0 +1,41 @@
// test/proba-hsm-incredere.mjs (2026-09-29, revizuirea adversariala a feliei HSM, H1): fisierul sigilat NU alege biblioteca PKCS#11 si
// nici variabila de mediu trimisa drept PIN. Nu cere un HSM: unealta primita e o cale care NU exista, deci orice verificare care lasa
// cererea sa ajunga la unealta se vede ca HSM_TOOL_MISSING, iar un refuz dat INAINTE de unealta are codul lui. Controlul pozitiv al
// metodei: o sigilare corecta ajunge chiar la unealta (HSM_TOOL_MISSING), deci refuzurile de mai jos nu vin din altceva.
// node test/proba-hsm-incredere.mjs -> 0 toate cum trebuia, 1 altfel
// AERE_HSM_MODUL_SUB_PROBA=<cale> numai pentru controlul negativ (o copie plantata a modulului)
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const MOD = process.env.AERE_HSM_MODUL_SUB_PROBA || path.join(AICI, '..', 'hsm-radacina.mjs');
const { deschideRadacina, radacinaDinHsm, FORMAT_HSM } = await import(pathToFileURL(MOD).href);
let rele = 0, n = 0;
const cer = (nume, ok, extra = '') => { n++; console.log(` [${ok ? 'OK ' : 'RAU '}] ${nume}${extra ? ' (' + extra + ')' : ''}`); if (!ok) rele++; };
const codul = (fn) => { try { fn(); return 'FARA REFUZ'; } catch (e) { return e.code || String(e.message).slice(0, 60); } };
const LIB = path.resolve('/usr/lib/softhsm/libsofthsm2.so');
const RAU = path.resolve('/tmp/biblioteca-straina.so');
const UNEALTA = path.join(os.tmpdir(), 'nu-exista-' + process.pid, 'pkcs11-tool');
const sig = { format: FORMAT_HSM, mecanism: 'AES-CBC-PAD', modul: LIB, token: 'aere-kms', idCheie: '0a', pinEnv: 'AERE_HSM_PIN', iv: '00'.repeat(16), ct: '11'.repeat(64) };
const env = { AERE_HSM_PIN: '483920', AERE_HSM_MODULE: LIB, AERE_KMS_TOKEN: 'AERE-SINTETIC-jeton-api-care-nu-trebuie-trimis-ca-pin' };
const deschide = (s, e = env) => codul(() => deschideRadacina(s, { env: e, unealta: UNEALTA }));
cer('CONTROL POZITIV: o sigilare corecta ajunge la unealta (HSM_TOOL_MISSING aici), deci refuzurile de mai jos vin din verificari', deschide(sig) === 'HSM_TOOL_MISSING', deschide(sig));
cer('H1: o biblioteca din fisier, alta decat AERE_HSM_MODULE -> HSM_MODULE_NOT_ALLOWED, fara sa ruleze unealta', deschide({ ...sig, modul: RAU }) === 'HSM_MODULE_NOT_ALLOWED', deschide({ ...sig, modul: RAU }));
cer('H1: fara AERE_HSM_MODULE -> HSM_CONFIG_INVALID (fisierul singur nu alege biblioteca)', deschide(sig, { ...env, AERE_HSM_MODULE: '' }) === 'HSM_CONFIG_INVALID');
cer('H1: AERE_HSM_MODULE relativ -> HSM_CONFIG_INVALID', deschide(sig, { ...env, AERE_HSM_MODULE: 'libsofthsm2.so' }) === 'HSM_CONFIG_INVALID');
cer('H1: fisierul cere alta variabila drept PIN (jetonul API) -> HSM_SEAL_INVALID, fara sa ruleze unealta', deschide({ ...sig, pinEnv: 'AERE_KMS_TOKEN' }) === 'HSM_SEAL_INVALID', deschide({ ...sig, pinEnv: 'AERE_KMS_TOKEN' }));
const { pinEnv, ...faraPinEnv } = sig;
cer('o sigilare fara camp pinEnv (PIN-ul din AERE_HSM_PIN) ajunge la unealta', deschide(faraPinEnv) === 'HSM_TOOL_MISSING');
// drumul serverului: AERE_KMS_ROOT_HSM spre un fisier cu biblioteca straina
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'hsm-incredere-'));
try {
const f = path.join(T, 'root-hsm.json'); fs.writeFileSync(f, JSON.stringify({ ...sig, modul: RAU }));
cer('H1 pe drumul serverului: AERE_KMS_ROOT_HSM cu biblioteca straina -> HSM_MODULE_NOT_ALLOWED', codul(() => radacinaDinHsm({ ...env, AERE_KMS_ROOT_HSM: f }, { unealta: UNEALTA })) === 'HSM_MODULE_NOT_ALLOWED');
} finally { fs.rmSync(T, { recursive: true, force: true }); }
console.log(`\nincrederea in fisierul sigilat (H1): ${n - rele}/${n} cum trebuia`);
process.exitCode = rele ? 1 : 0;

88
pq-kms/test/proba-hsm.mjs Normal file
View File

@ -0,0 +1,88 @@
// test/proba-hsm.mjs - proba radacinii KMS sigilate de un HSM (hsm-radacina.mjs), pe un HSM PKCS#11 REAL (SoftHSM2), nu pe o imitatie
// a uneltei: token nou intr-un dosar temporar, cheie AES-256 generata IN token (sensitive, never extractable), apoi sigilare,
// deschidere, KMS-ul pornit din radacina deschisa si o runda hibrida (ML-KEM-768 + X25519) prin el. Fiecare refuz se judeca dupa
// MOTIV (codul), nu dupa faptul ca a aruncat ceva.
// Cere: Node 24 (kms.mjs), softhsm2-util si pkcs11-tool (OpenSC) in PATH; libsofthsm2.so. Ruleaza in WSL / Linux:
// /root/node24/bin/node test/proba-hsm.mjs -> 0 toate cum trebuia, 1 altfel, 2 NEMASURAT (HSM-ul de proba nu porneste)
import crypto from 'node:crypto';
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';
import { sigileazaRadacina, deschideRadacina, radacinaDinHsm, FORMAT_HSM } from '../hsm-radacina.mjs';
import { configFromEnv } from '../server.mjs';
import { openKms } from '../kms.mjs';
const MODUL = process.env.AERE_PKCS11_MODUL || '/usr/lib/softhsm/libsofthsm2.so';
let rele = 0, n = 0;
const cer = (nume, ok, extra = '') => { n++; console.log(` [${ok ? 'OK ' : 'RAU '}] ${nume}${extra ? ' (' + extra + ')' : ''}`); if (!ok) rele++; };
const codul = (fn) => { try { fn(); return 'FARA REFUZ'; } catch (e) { return e.code || String(e.message).slice(0, 60); } };
const nemasurat = (m) => { console.log('NEMASURAT: ' + m); process.exit(2); };
if (!fs.existsSync(MODUL)) nemasurat('lipseste modulul PKCS#11 ' + MODUL);
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'proba-hsm-'));
// 2026-09-29 (H1): deschiderea cere biblioteca din configuratia procesului, nu din fisierul sigilat
const env = { ...process.env, SOFTHSM2_CONF: path.join(T, 'softhsm2.conf'), AERE_HSM_PIN: '483920', AERE_HSM_MODULE: MODUL };
fs.mkdirSync(path.join(T, 'tokens'));
fs.writeFileSync(env.SOFTHSM2_CONF, `directories.tokendir = ${path.join(T, 'tokens')}\nobjectstore.backend = file\nlog.level = ERROR\n`);
const ruleaza = (c, a) => spawnSync(c, a, { env, encoding: 'utf8' });
try {
let r = ruleaza('softhsm2-util', ['--init-token', '--free', '--label', 'aere-kms', '--pin', env.AERE_HSM_PIN, '--so-pin', '771122']);
if (r.status !== 0) nemasurat('softhsm2-util nu initializeaza tokenul: ' + (r.stderr || r.error));
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--keygen', '--key-type', 'AES:32', '--label', 'kms-root', '--id', '0a', '--sensitive']);
if (r.status !== 0) nemasurat('cheia AES nu se genereaza in token: ' + (r.stderr || r.error));
cer('cheia AES e generata IN token: sensitive, never extractable', /never extractable/.test(r.stdout) && /sensitive/.test(r.stdout));
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--read-object', '--type', 'secrkey', '--id', '0a', '--output-file', path.join(T, 'k')]);
cer('cheia NU poate fi citita afara din HSM', r.status !== 0 && (!fs.existsSync(path.join(T, 'k')) || fs.statSync(path.join(T, 'k')).size === 0));
const radacina = crypto.randomBytes(32);
const sig = sigileazaRadacina({ modul: MODUL, token: 'aere-kms', idCheie: '0a', radacina: Buffer.from(radacina), env });
cer('sigilarea: formatul, 64 de octeti, si radacina NU apare in ea', sig.format === FORMAT_HSM && sig.ct.length === 128 && !JSON.stringify(sig).includes(radacina.toString('hex')));
const d = deschideRadacina(sig, { env });
cer('deschiderea cu HSM-ul si PIN-ul da exact radacina sigilata', d.equals(radacina));
const s2 = sigileazaRadacina({ modul: MODUL, token: 'aere-kms', idCheie: '0a', radacina: Buffer.from(radacina), env });
cer('doua sigilari ale aceleiasi radacini difera (iv aleator), amandoua se deschid', s2.ct !== sig.ct && deschideRadacina(s2, { env }).equals(radacina));
// refuzuri, fiecare cu motivul lui
cer('PIN gresit -> HSM_LOGIN_REFUSED', codul(() => deschideRadacina(sig, { env: { ...env, AERE_HSM_PIN: '000000' } })) === 'HSM_LOGIN_REFUSED');
cer('PIN lipsa -> HSM_PIN_MISSING (nu se incearca fara)', codul(() => deschideRadacina(sig, { env: { ...env, AERE_HSM_PIN: '' } })) === 'HSM_PIN_MISSING');
cer('alt token -> HSM_TOKEN_NOT_FOUND', codul(() => deschideRadacina({ ...sig, token: 'alt-token' }, { env })) === 'HSM_TOKEN_NOT_FOUND');
const cti = Buffer.from(sig.ct, 'hex'); cti[3] ^= 0x01; // primul bloc: CBC descifreaza, dar in altceva
cer('sigilare atinsa in primul bloc -> HSM_ROOT_TAMPERED (amprenta), nu o radacina gresita', codul(() => deschideRadacina({ ...sig, ct: cti.toString('hex') }, { env })) === 'HSM_ROOT_TAMPERED');
const ctu = Buffer.from(sig.ct, 'hex'); ctu[63] ^= 0x80; // ultimul bloc: umplutura strica
cer('sigilare atinsa in ultimul bloc -> HSM_ROOT_TAMPERED', codul(() => deschideRadacina({ ...sig, ct: ctu.toString('hex') }, { env })) === 'HSM_ROOT_TAMPERED');
// H2 (2026-09-29): umplutura stricata (ultimul bloc) si amprenta gresita (primul bloc) dau ACELASI mesaj: altfel jurnalul pornirii e un oracol de umplutura
const mesaj = (s) => { try { deschideRadacina(s, { env }); return 'FARA REFUZ'; } catch (e) { return e.code + ': ' + e.message; } };
const m1 = mesaj({ ...sig, ct: cti.toString('hex') }), m2 = mesaj({ ...sig, ct: ctu.toString('hex') });
cer('H2: umplutura stricata si amprenta gresita nu se deosebesc prin mesaj', m1 === m2 && m1.startsWith('HSM_ROOT_TAMPERED'), m1 === m2 ? 'identice' : m1 + ' | ' + m2);
// CONTROLUL NECESITATII amprentei: fara ea, CBC ar fi dat 48 de octeti de gunoi, deci amprenta e cea care prinde atingerea
const ctf = path.join(T, 'c'), ptf = path.join(T, 'p'); fs.writeFileSync(ctf, cti);
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--id', '0a', '--mechanism', 'AES-CBC-PAD', '--decrypt', '--iv', sig.iv, '--input-file', ctf, '--output-file', ptf]);
cer('CONTROL: HSM-ul singur descifreaza sigilarea atinsa fara sa se planga (48 de octeti) - de aceea amprenta', r.status === 0 && fs.statSync(ptf).size === 48);
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--keygen', '--key-type', 'AES:32', '--label', 'alta', '--id', '0b', '--sensitive']);
cer('alta cheie din acelasi token -> HSM_ROOT_TAMPERED (nu se deschide cu cheia gresita)', r.status === 0 && codul(() => deschideRadacina({ ...sig, idCheie: '0b' }, { env })) === 'HSM_ROOT_TAMPERED');
// KMS-ul pornit din radacina deschisa de HSM, capat la capat
const fsig = path.join(T, 'root-hsm.json'); fs.writeFileSync(fsig, JSON.stringify(sig));
const tok = crypto.randomBytes(24).toString('hex');
const cfg = configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok, AERE_KMS_DATA_DIR: path.join(T, 'date') });
cer('serverul ia radacina din HSM (rootSource=hsm) si e chiar cea sigilata', cfg.rootSource === 'hsm' && cfg.rootKey === radacina.toString('hex'));
const kms = openKms({ dataDir: cfg.dataDir, rootKey: cfg.rootKey });
kms.createKey('client', { type: 'encrypt' });
const c = kms.encrypt('client', 'date de pastrat', 'ctx').ciphertext;
cer('KMS pornit din radacina HSM: runda hibrida X25519 + ML-KEM-768', kms.decrypt('client', c, 'ctx').plaintext.toString() === 'date de pastrat');
kms.close();
const cfg2 = configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok, AERE_KMS_DATA_DIR: path.join(T, 'date') });
const kms2 = openKms({ dataDir: cfg2.dataDir, rootKey: cfg2.rootKey });
cer('redeschis din HSM, KMS-ul descifreaza ce a cifrat inainte (aceeasi radacina)', kms2.decrypt('client', c, 'ctx').plaintext.toString() === 'date de pastrat');
kms2.close();
cer('amandoua sursele de radacina deodata -> ROOT_KEY_AMBIGUOUS', codul(() => configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_ROOT_KEY: radacina.toString('hex'), AERE_KMS_TOKEN: tok })) === 'ROOT_KEY_AMBIGUOUS');
cer('sigilarea lipsa -> HSM_SEAL_INVALID', codul(() => radacinaDinHsm({ ...env, AERE_KMS_ROOT_HSM: path.join(T, 'nu-exista.json') })) === 'HSM_SEAL_INVALID');
cer('cu PIN gresit serverul REFUZA sa porneasca (nu cade pe alta radacina)', codul(() => configFromEnv({ ...env, AERE_HSM_PIN: '999999', AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok })) === 'HSM_LOGIN_REFUSED');
// /dev/shm curat dupa operatii: fisierele temporare cu radacina nu raman
const ramase = fs.existsSync('/dev/shm') ? fs.readdirSync('/dev/shm').filter((x) => x.startsWith('aerekms-hsm-')) : [];
cer('nicio urma a radacinii in /dev/shm dupa operatii', ramase.length === 0, ramase.join(','));
} finally { fs.rmSync(T, { recursive: true, force: true }); }
console.log(`\nradacina KMS sigilata de HSM (SoftHSM2 + OpenSC): ${n - rele}/${n} cum trebuia`);
process.exitCode = rele ? 1 : 0;

1339
pq-kms/test/proba.mjs Normal file

File diff suppressed because it is too large Load Diff

67
pq-pki/README.md Normal file
View File

@ -0,0 +1,67 @@
# Aere PQ PKI
A private certificate authority that issues **post-quantum X.509 v3 certificates** signed with **ML-DSA** (FIPS 204, the pure form with an empty context, as RFC 9881 specifies for certificates), revocation lists signed the same way, and a strict chain verifier. Node 24 with its bundled OpenSSL 3.5, no dependencies.
With a chain from this CA, a TLS 1.3 server is post-quantum end to end: the key exchange is `X25519MLKEM768` and the server authenticates with an `mldsa65` signature. Measured in the probes: a Node server with our chain, and both `openssl s_client` (3.5) and a Node client accept it (`Verify return code: 0`, `Peer signature type: mldsa65`, `Negotiated TLS1.3 group: X25519MLKEM768`).
```
export AERE_PKI_PASSPHRASE='...12+ characters with 3 character classes, or 20+ characters...'
node cli.mjs init --dir ca --name root --org "Example" # ML-DSA-87 root, pathLen 1, 10 years
node cli.mjs intermediate --dir ca --ca root --name issuing # ML-DSA-65, pathLen 0, 5 years
node cli.mjs issue --dir ca --ca issuing --cn api.internal --dns api.internal --out api # 90 days, serverAuth
node cli.mjs revoke --dir ca --ca issuing --cert api.crt
node cli.mjs crl --dir ca --ca issuing --out issuing.crl
node cli.mjs verify --roots ca/root.crt --chain api.chain.pem --host api.internal --crl issuing.crl
node cli.mjs unseal --key api.key --out api.p8.pem # unencrypted PKCS#8 for a TLS server, only on request
```
Nothing is ever overwritten, and without `AERE_PKI_PASSPHRASE` nothing is issued.
## Private keys on disk
Private keys are written **sealed** in a format of our own, never as PKCS#8:
```
-----BEGIN AERE PQ PKI PRIVATE KEY-----
SEQUENCE {
header SEQUENCE { version 1, "scrypt", salt (16 bytes), N=131072, r=8, p=1, "aes-256-gcm", iv (12 bytes) },
tag (16 bytes),
ciphertext -- the PKCS#8 DER of the key, AES-256-GCM under scrypt(passphrase, salt), with the header as AAD
}
-----END AERE PQ PKI PRIVATE KEY-----
```
Why: the PKCS#8 encryption that `node:crypto` produces (`key.export({cipher})`) derives its key with PBKDF2 at **2048 iterations**, which the adversarial review measured at 3 to 5 ms per passphrase guess. With scrypt at N=2^17, r=8, p=1 (128 MiB of memory per guess) one guess costs **about 0.7 to 1.0 s on the development laptop** (Intel i7-10870H, Node 24 `scryptSync`; the probe measures it on every run and prints the number, and fails under 25 ms). The header is bound as AAD, so a changed parameter, salt, IV or ciphertext byte fails authentication exactly like a wrong passphrase (`KEY_LOCKED`); the reader accepts N only between 2^14 and 2^20 (`KEY_FORMAT` otherwise), so a crafted file cannot make it allocate gigabytes. **PKCS#8 files, encrypted or not, are refused by `importPrivateKey` with `KEY_FORMAT`**; a key made before this format has to be re-sealed.
The passphrase policy (`checkPassphrase`, applied when a key is written): at least 12 characters and at least 3 of the 4 classes (lower, upper, digit, other), or at least 20 characters; one repeated character is refused; the obvious ones (`password`, `qwerty`, `123456`, `letmein`, ... with or without digits and punctuation appended) are refused. Refusals carry `PASSPHRASE`.
A Node TLS server loads the key in process, without ever writing it in clear: `tls.createServer({ key: importPrivateKey(fs.readFileSync('api.key', 'utf8'), passphrase).export({ type: 'pkcs8', format: 'pem' }), cert })`. For servers that only read files, `node cli.mjs unseal` writes an unencrypted PKCS#8 file with mode 0600 and never overwrites; delete it when it is no longer needed. `readSealedKeyHeader(pem)` returns the KDF parameters without opening the key.
## What the verifier checks
`verifyChain` always returns a verdict `{ ok, code, reason, chain }`; it never throws, whatever bytes it is given (the probe feeds it hundreds of random mutations, truncations and garbage).
- **Path building**: issuer/subject matched byte-exactly, key identifiers matched when present, and **every candidate path is tried**, trust anchors first: with a renewed root (two anchors with the same name and key, one expired) or a cross-signed intermediate listed before the right one, the valid path is found. If no path validates, the failure of the last path tried is returned (`UNTRUSTED` when no path reaches an anchor).
- **Every link**: a valid ML-DSA signature, a CA issuer (`basicConstraints`) with `keyCertSign`, the issuer's `pathLen`, validity at the time of use, no unknown critical extension; the trusted root must be a valid self-signed certificate.
- **Public keys**: the `subjectPublicKeyInfo` of every certificate must carry a key of exactly the FIPS 204 length for its algorithm (1312 / 1952 / 2592 bytes for ML-DSA-44 / 65 / 87) with no unused bits; anything else is the verdict `SPKI`, on the leaf too (its key is never used by the verifier, so without this check a 10-byte "key" passed).
- **Extended key usage on authorities**: when an issuer in the chain, root included, carries an EKU, the purpose asked for must be in it (verdict `EKU`, naming the authority), as OpenSSL, Chrome and Mozilla do. `anyExtendedKeyUsage` does not satisfy a purpose, as in OpenSSL.
- **Revocation**, for each issuer in the chain: among that issuer's lists that verify under its key and are not dated in the future, **the one with the newest `thisUpdate` is used** (ties broken by the larger `crlNumber`); the order of the input does not matter. That list must be current (`CRL_STALE` after `nextUpdate`), and a listed serial is `REVOKED`. `requireCrl` makes a missing list `CRL_MISSING`. A list whose signature does not verify is `CRL_SIGNATURE`.
- **CRL extensions**: `authorityKeyIdentifier` and `crlNumber` are understood; `issuingDistributionPoint` and `deltaCRLIndicator` are refused however they are marked, and so is any other critical extension of the list or of an entry (`certificateIssuer` of an indirect list, for instance): verdict `CRL_EXT`, naming the extension. Non-critical unknown extensions (a `reasonCode` on an entry, for instance) are ignored.
- **End entity**: not a CA, `digitalSignature`, the extended key usage of the purpose, and the host name or IPv4 address in the subject alternative name.
Every refusal carries a code: `SIGNATURE`, `UNTRUSTED`, `ROOT`, `EXPIRED`, `NOT_YET_VALID`, `NOT_CA`, `KEY_USAGE`, `PATH_LEN`, `EKU`, `SPKI`, `REVOKED`, `CRL_MISSING`, `CRL_SIGNATURE`, `CRL_STALE`, `CRL_EXT`, `LEAF_IS_CA`, `HOSTNAME`, `CRITICAL_EXT`, `ALG`, `ALG_MISMATCH`, `ALG_PARAMS`, `VERSION`, `EXT_DUP`, `DER`. The DER parser is strict: no indefinite lengths, no non-minimal lengths, no trailing bytes, no non-canonical integers or object identifiers; object identifiers with a multi-byte first subidentifier (`2.999` is `06 02 88 37`) encode and decode as OpenSSL does.
## How it is proven
`node test/proba.mjs` (27 probes, about 25 s) compares every verdict with a foreign implementation, the OpenSSL 3.5 command line in `-x509_strict` mode: the valid chain, revocation, the newest of several lists (both orders), lists made by `openssl ca -gencrl` (plain, with a revoked entry and a reason code, with a critical issuing distribution point, with an unknown critical extension), a delta list (OpenSSL refuses it only with `-extended_crl`), a changed byte, another root, expiry and not-yet-valid, a path-length violation, a non-CA issuer (a chain made by OpenSSL), host and IP names, purpose on the leaf and on authorities (`unsuitable certificate purpose` at depth 1), a renewed root (both anchors in one `CAfile`), malformed public keys (`decode error`), an unknown critical extension (made by OpenSSL), object identifier bytes (`asn1parse -genstr`). It also reads and verifies certificates made by OpenSSL, runs the real TLS handshake, measures the cost of a passphrase guess, exercises the passphrase policy, and drives the command line end to end. `node test/control-negativ.mjs` (about 3 minutes, four plantings in parallel) breaks each check in a copy of the code (22 plantings, at least one per finding of the review) and requires the named probe to turn red, the original to stay byte-identical, and the untouched suite to stay green; the number of probes is taken from the untouched run, not written down.
## What it does not do, and where it differs from OpenSSL
- **Composite (hybrid classical + ML-DSA) certificates**: still IETF drafts; this CA issues pure ML-DSA certificates. A client that only knows classical algorithms cannot verify them.
- **OCSP, name constraints, certificate policies, IPv6 names, delta and partial (IDP) revocation lists**: not implemented; delta and IDP lists are refused (`CRL_EXT`) rather than misread. OpenSSL without `-extended_crl` silently treats a delta list as a complete one; we do not.
- **A list without `nextUpdate`** is accepted for as long as it is the newest one, as OpenSSL does; an operator who wants freshness enforced must publish lists with `nextUpdate`.
- **A leaf without `keyUsage`** is refused here (`KEY_USAGE`); OpenSSL `-x509_strict` accepts it.
- **Backtracking between intermediates**: we find the valid path when a cross-signed intermediate is listed first; OpenSSL does not and refuses that input.
- **Name comparison** is byte-exact on the DER encoding, not the full RFC 5280 normalization; chains issued here always encode names the same way.
- **The CA key is protected by a passphrase on disk** (scrypt + AES-256-GCM, above), not by an HSM. Keep the root offline. **PKCS#8 is no longer the on-disk format**; `unseal` produces it only on request.
- The second implementation used in the probes (OpenSSL) shares the ML-DSA code with Node, since Node bundles OpenSSL; the X.509 encoding, path building and revocation logic are independent.

134
pq-pki/cli.mjs Normal file
View File

@ -0,0 +1,134 @@
#!/usr/bin/env node
// cli.mjs: linia de comanda a autoritatii de certificare post-cuantice (pki.mjs). Starea sta intr-un dosar: fiecare CA are
// <nume>.crt (PEM), <nume>.key (cheia SIGILATA cu parola din AERE_PKI_PASSPHRASE: scrypt + AES-256-GCM, formatul propriu din pki.mjs,
// niciodata in clar si niciodata PKCS#8 pe disc [A1]), <nume>.revoked.json (seriile revocate) si <nume>.crlnum (numarul ultimei liste).
// "unseal" scrie o cheie in PKCS#8 necifrat, singura forma pe care o citesc serverele TLS straine; se face numai la cerere explicita.
// Mesajele pentru utilizator sunt in engleza.
import fs from 'node:fs';
import path from 'node:path';
import * as P from './pki.mjs';
const USAGE = `aere-pq-pki: a private post-quantum certificate authority (X.509 v3, ML-DSA, RFC 9881)
node cli.mjs init --dir D --name NAME [--org O] [--alg ml-dsa-87] [--days 3650] [--path-len 1]
node cli.mjs intermediate --dir D --ca ROOT --name NAME [--org O] [--alg ml-dsa-65] [--days 1825] [--path-len 0]
node cli.mjs issue --dir D --ca CA --cn CN [--dns a,b] [--ip 192.0.2.10] [--client] [--alg ml-dsa-65] [--days 90] --out PREFIX
node cli.mjs revoke --dir D --ca CA --cert FILE
node cli.mjs crl --dir D --ca CA [--days 7] --out FILE
node cli.mjs verify --roots FILE --chain FILE [--host H] [--crl FILE ...] [--purpose serverAuth|clientAuth] [--require-crl]
node cli.mjs unseal --key FILE.key --out FILE.pem (writes the key as UNENCRYPTED PKCS#8, for a TLS server; keep it 0600)
Private keys are written sealed with AERE_PKI_PASSPHRASE (scrypt 2^17 + AES-256-GCM): at least 12 characters with 3 character classes,
or at least 20 characters; obvious passphrases are refused. Without it nothing is issued.`;
function args(argv) {
const o = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) { o._.push(a); continue; }
const k = a.slice(2);
const flag = ['client', 'require-crl'].includes(k);
const v = flag ? true : argv[++i];
if (v === undefined) throw new P.PkiError('USAGE', '--' + k + ' needs a value');
if (k === 'crl') (o.crl ||= []).push(v); else o[k] = v;
}
return o;
}
// La deschidere se cere doar sa existe (o cheie sigilata a trecut politica atunci cand a fost scrisa); politica intreaga
// (P.checkPassphrase) se aplica la SCRIERE, in exportPrivateKey, ca o inasprire viitoare sa nu incuie cheile vechi.
const pass = () => {
const p = process.env.AERE_PKI_PASSPHRASE;
if (!p || p.length < 12) throw new P.PkiError('PASSPHRASE', 'set AERE_PKI_PASSPHRASE (at least 12 characters with 3 character classes, or at least 20 characters)');
return p;
};
const need = (o, ...ks) => { for (const k of ks) if (!o[k]) throw new P.PkiError('USAGE', '--' + k + ' is required'); };
const safeName = (n) => { if (!/^[A-Za-z0-9._-]{1,64}$/.test(n)) throw new P.PkiError('USAGE', 'names use letters, digits, ".", "_" and "-" only'); return n; };
function loadCa(dir, ca) {
const n = safeName(ca);
const cert = P.unpem(fs.readFileSync(path.join(dir, n + '.crt'), 'utf8'))[0];
const key = P.importPrivateKey(fs.readFileSync(path.join(dir, n + '.key'), 'utf8'), pass());
return { n, cert, key };
}
function writeNew(file, data, mode = 0o600) {
if (fs.existsSync(file)) throw new P.PkiError('EXISTS', file + ' exists; nothing is overwritten');
fs.writeFileSync(file, data, { mode, flag: 'wx' });
}
function main(argv) {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help') { console.log(USAGE); return 0; }
const o = args(rest);
if (cmd === 'init' || cmd === 'intermediate') {
need(o, 'dir', 'name');
const n = safeName(o.name);
fs.mkdirSync(o.dir, { recursive: true, mode: 0o700 });
const alg = o.alg || (cmd === 'init' ? 'ml-dsa-87' : 'ml-dsa-65');
const k = P.generateKey(alg);
const pl = o['path-len'] !== undefined ? Number(o['path-len']) : (cmd === 'init' ? 1 : 0);
let cert;
if (cmd === 'init') cert = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 3650) });
else { need(o, 'ca'); const ca = loadCa(o.dir, o.ca); cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 1825) }); }
const pem = P.exportPrivateKey(k.privateKey, pass());
writeNew(path.join(o.dir, n + '.key'), pem);
writeNew(path.join(o.dir, n + '.crt'), P.pem('CERTIFICATE', cert), 0o644);
writeNew(path.join(o.dir, n + '.revoked.json'), '[]\n');
console.log(`${cmd === 'init' ? 'root' : 'intermediate'} "${o.name}" (${alg}) written to ${o.dir}`);
return 0;
}
if (cmd === 'issue') {
need(o, 'dir', 'ca', 'cn', 'out');
const ca = loadCa(o.dir, o.ca);
const alg = o.alg || 'ml-dsa-65';
const k = P.generateKey(alg);
const cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.cn }, publicKey: k.publicKey, days: Number(o.days || 90),
dns: o.dns ? String(o.dns).split(',') : [], ips: o.ip ? String(o.ip).split(',') : [], eku: [o.client ? 'clientAuth' : 'serverAuth'] });
writeNew(o.out + '.key', P.exportPrivateKey(k.privateKey, pass()));
writeNew(o.out + '.crt', P.pem('CERTIFICATE', cert), 0o644);
writeNew(o.out + '.chain.pem', P.pem('CERTIFICATE', cert) + P.pem('CERTIFICATE', ca.cert), 0o644);
console.log(`certificate for "${o.cn}" (${alg}, serial ${P.parseCert(cert).serial.toString('hex')}) written to ${o.out}.crt`);
return 0;
}
if (cmd === 'revoke') {
need(o, 'dir', 'ca', 'cert');
const ca = loadCa(o.dir, o.ca);
const c = P.parseCert(P.unpem(fs.readFileSync(o.cert, 'utf8'))[0]);
if (!c.issuerRaw.equals(P.parseCert(ca.cert).subjectRaw)) throw new P.PkiError('ISSUER', 'this certificate was not issued by ' + ca.n);
const f = path.join(o.dir, ca.n + '.revoked.json');
const list = JSON.parse(fs.readFileSync(f, 'utf8'));
const s = c.serial.toString('hex');
if (!list.some((x) => x.serial === s)) list.push({ serial: s, date: new Date().toISOString() });
fs.writeFileSync(f, JSON.stringify(list, null, 1) + '\n');
console.log(`serial ${s} revoked by ${ca.n}; publish a new list with "crl"`);
return 0;
}
if (cmd === 'crl') {
need(o, 'dir', 'ca', 'out');
const ca = loadCa(o.dir, o.ca);
const nf = path.join(o.dir, ca.n + '.crlnum');
const number = (fs.existsSync(nf) ? Number(fs.readFileSync(nf, 'utf8')) : 0) + 1;
const list = JSON.parse(fs.readFileSync(path.join(o.dir, ca.n + '.revoked.json'), 'utf8'));
const der = P.crl({ issuer: ca.cert, signingKey: ca.key, revoked: list, days: Number(o.days || 7), number });
fs.writeFileSync(o.out, P.pem('X509 CRL', der));
fs.writeFileSync(nf, String(number));
console.log(`revocation list #${number} of ${ca.n} (${list.length} revoked) written to ${o.out}`);
return 0;
}
if (cmd === 'verify') {
need(o, 'roots', 'chain');
const chain = P.unpem(fs.readFileSync(o.chain, 'utf8'));
const r = P.verifyChain({ leaf: chain[0], intermediates: chain.slice(1), roots: P.unpem(fs.readFileSync(o.roots, 'utf8')),
host: o.host || null, purpose: o.purpose || 'serverAuth', requireCrl: !!o['require-crl'],
crls: (o.crl || []).flatMap((f) => P.unpem(fs.readFileSync(f, 'utf8'), 'X509 CRL')) });
console.log(r.ok ? `OK: ${r.chain.join(' <- ')}` : `REFUSED (${r.code}): ${r.reason}`);
return r.ok ? 0 : 1;
}
if (cmd === 'unseal') {
need(o, 'key', 'out');
const key = P.importPrivateKey(fs.readFileSync(o.key, 'utf8'), pass());
writeNew(o.out, key.export({ type: 'pkcs8', format: 'pem' }));
console.log(`${o.key} unsealed to ${o.out} as UNENCRYPTED PKCS#8 (mode 0600); delete it when the server no longer needs it`);
return 0;
}
throw new P.PkiError('USAGE', 'unknown command ' + cmd + '\n' + USAGE);
}
try { process.exitCode = main(process.argv.slice(2)); }
catch (e) { console.error(`error (${e.code || 'ERROR'}): ${e.message}`); process.exitCode = 2; }

165
pq-pki/der.mjs Normal file
View File

@ -0,0 +1,165 @@
// der.mjs: codor si decodor DER minimal (ASN.1), numai ce trebuie pentru X.509 v3 si CRL v2. Fara dependinte.
// Decodorul e STRICT: lungimi in forma cea mai scurta, fara lungime nedefinita, fara octeti in plus; un DER necanonic e o eroare,
// nu o interpretare (doua codificari ale aceluiasi certificat ar da doua amprente).
export const TAG = {
BOOLEAN: 0x01, INTEGER: 0x02, BIT_STRING: 0x03, OCTET_STRING: 0x04, NULL: 0x05, OID: 0x06, UTF8: 0x0c,
PRINTABLE: 0x13, IA5: 0x16, UTC_TIME: 0x17, GEN_TIME: 0x18, SEQUENCE: 0x30, SET: 0x31,
};
function lenBytes(n) {
if (n < 0x80) return Buffer.from([n]);
const out = [];
while (n > 0) { out.unshift(n & 0xff); n = Math.floor(n / 256); }
return Buffer.from([0x80 | out.length, ...out]);
}
export function tlv(tag, content) {
const c = Buffer.isBuffer(content) ? content : Buffer.from(content);
return Buffer.concat([Buffer.from([tag]), lenBytes(c.length), c]);
}
export const seq = (...items) => tlv(TAG.SEQUENCE, Buffer.concat(items.filter(Boolean)));
export const set = (...items) => tlv(TAG.SET, Buffer.concat(items.filter(Boolean)));
export const ctx = (n, content, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, content);
export const octets = (b) => tlv(TAG.OCTET_STRING, b);
export const bits = (b, unused = 0) => tlv(TAG.BIT_STRING, Buffer.concat([Buffer.from([unused]), b]));
export const bool = (v) => tlv(TAG.BOOLEAN, Buffer.from([v ? 0xff : 0x00]));
export const utf8 = (s) => tlv(TAG.UTF8, Buffer.from(s, 'utf8'));
export const ia5 = (s) => tlv(TAG.IA5, Buffer.from(s, 'ascii'));
/** INTEGER din Buffer fara semn (big-endian) sau din numar/BigInt nenegativ, in forma DER minima. */
export function int(v) {
let b;
if (Buffer.isBuffer(v)) b = v;
else {
let x = BigInt(v);
if (x < 0n) throw new Error('DER: numai intregi nenegativi');
const a = [];
do { a.unshift(Number(x & 0xffn)); x >>= 8n; } while (x > 0n);
b = Buffer.from(a);
}
let i = 0;
while (i < b.length - 1 && b[i] === 0 && (b[i + 1] & 0x80) === 0) i++;
b = b.subarray(i);
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
return tlv(TAG.INTEGER, b);
}
/** Un subidentificator in baza 128, cu bitul 7 pus pe toti octetii in afara de ultimul (X.690 8.19.2). */
function base128(x) {
const a = [Number(x & 0x7fn)]; x >>= 7n;
while (x > 0n) { a.unshift(Number(x & 0x7fn) | 0x80); x >>= 7n; }
return a;
}
export function oid(dotted) {
const p = String(dotted).split('.').map((x) => { if (!/^\d+$/.test(x)) throw new Error('DER: OID cu componenta nevalida: ' + dotted); return BigInt(x); });
if (p.length < 2) throw new Error('DER: OID prea scurt');
if (p[0] > 2n || (p[0] < 2n && p[1] > 39n)) throw new Error('DER: OID cu arc nevalid: ' + dotted);
// [A10] primul subidentificator poarta impreuna arcul si al doilea numar (40*arc + al doilea) si, ca oricare altul, se scrie in baza 128
// pe mai multi octeti cand trece de 127: 2.999 e 88 37, nu un singur octet trunchiat. Pe arcul 2 al doilea numar nu are limita.
const out = base128(p[0] * 40n + p[1]);
for (const v of p.slice(2)) out.push(...base128(v));
return tlv(TAG.OID, Buffer.from(out));
}
/** Timp X.509: UTCTime pana in 2049, GeneralizedTime dupa (RFC 5280 4.1.2.5), cu secunde si Z. */
export function time(d) {
const y = d.getUTCFullYear();
const pad = (n, w = 2) => String(n).padStart(w, '0');
const rest = pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + 'Z';
if (y >= 1950 && y < 2050) return tlv(TAG.UTC_TIME, Buffer.from(pad(y % 100) + rest, 'ascii'));
return tlv(TAG.GEN_TIME, Buffer.from(pad(y, 4) + rest, 'ascii'));
}
// ------------------------------------------------------------------------------------------------ decodare
/** Un element: { tag, start, hdr, len, end, raw (tot TLV-ul), content } peste acelasi Buffer. */
export function read(buf, off = 0) {
if (off + 2 > buf.length) throw new Error('DER: trunchiat la antet');
const tag = buf[off];
if ((tag & 0x1f) === 0x1f) throw new Error('DER: eticheta cu forma lunga nesuportata');
let l = buf[off + 1], hdr = 2;
if (l === 0x80) throw new Error('DER: lungime nedefinita (BER), refuzata');
if (l & 0x80) {
const n = l & 0x7f;
if (n === 0 || n > 4) throw new Error('DER: lungime pe ' + n + ' octeti');
if (off + 2 + n > buf.length) throw new Error('DER: trunchiat la lungime');
if (buf[off + 2] === 0) throw new Error('DER: lungime cu zero initial (necanonica)');
l = 0;
for (let i = 0; i < n; i++) l = l * 256 + buf[off + 2 + i];
if (l < 0x80) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');
hdr = 2 + n;
}
const end = off + hdr + l;
if (end > buf.length) throw new Error('DER: continut trunchiat');
return { tag, start: off, hdr, len: l, end, raw: buf.subarray(off, end), content: buf.subarray(off + hdr, end) };
}
/** Toate elementele din continutul unui element construit (SEQUENCE, SET, [n]). */
export function children(el) {
const out = [];
let o = 0;
while (o < el.content.length) { const c = read(el.content, o); out.push(c); o = c.end; }
return out;
}
/** Decodeaza un document DER intreg si refuza octetii in plus. */
export function parse(buf) {
const el = read(buf, 0);
if (el.end !== buf.length) throw new Error('DER: ' + (buf.length - el.end) + ' octeti dupa structura');
return el;
}
export function expect(el, tag, what) {
if (el.tag !== tag) throw new Error(`DER: ${what}: eticheta 0x${el.tag.toString(16)}, asteptat 0x${tag.toString(16)}`);
return el;
}
export function oidToString(el) {
expect(el, TAG.OID, 'OID');
const b = el.content;
if (b.length === 0) throw new Error('DER: OID gol');
if (b[b.length - 1] & 0x80) throw new Error('DER: OID trunchiat');
// [A10] intai se citesc TOTI subidentificatorii in baza 128 (si primul poate avea mai multi octeti), abia apoi primul se desparte in
// arc si al doilea numar: sub 80 arcul e catul impartirii la 40, de la 80 in sus arcul e 2 (X.690 8.19.4). Un octet 0x80 la inceputul
// unui subidentificator e o codificare necanonica si se refuza.
const subs = []; let v = 0n, inceput = true;
for (let i = 0; i < b.length; i++) {
if (inceput && b[i] === 0x80) throw new Error('DER: OID necanonic');
v = (v << 7n) | BigInt(b[i] & 0x7f); inceput = false;
if ((b[i] & 0x80) === 0) { subs.push(v); v = 0n; inceput = true; }
}
const first = subs[0];
const arc = first < 80n ? first / 40n : 2n;
return [arc, first - arc * 40n, ...subs.slice(1)].map(String).join('.');
}
export function intToBigInt(el) {
expect(el, TAG.INTEGER, 'INTEGER');
const b = el.content;
if (b.length === 0) throw new Error('DER: INTEGER gol');
if (b.length > 1 && ((b[0] === 0 && (b[1] & 0x80) === 0) || (b[0] === 0xff && (b[1] & 0x80)))) throw new Error('DER: INTEGER necanonic');
if (b[0] & 0x80) throw new Error('DER: INTEGER negativ');
return BigInt('0x' + (b.toString('hex') || '0'));
}
export function timeToDate(el) {
const s = el.content.toString('ascii');
let m;
if (el.tag === TAG.UTC_TIME && (m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s))) {
const yy = +m[1]; const y = yy >= 50 ? 1900 + yy : 2000 + yy;
return new Date(Date.UTC(y, +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
}
if (el.tag === TAG.GEN_TIME && (m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s))) {
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
}
throw new Error('DER: timp in forma nepermisa de RFC 5280: ' + s);
}
export function bitString(el) {
expect(el, TAG.BIT_STRING, 'BIT STRING');
if (el.content.length === 0) throw new Error('DER: BIT STRING gol');
return { unused: el.content[0], bytes: el.content.subarray(1) };
}

520
pq-pki/pki.mjs Normal file
View File

@ -0,0 +1,520 @@
// pki.mjs: autoritate de certificare post-cuantica privata. Emite certificate X.509 v3 semnate cu ML-DSA (FIPS 204, forma "pura",
// context gol, cum cere RFC 9881 pentru certificate), liste de revocare v2 semnate la fel, si verifica un lant strict (RFC 5280, partea
// de care are nevoie un TLS privat). Numai Node 24 + OpenSSL 3.5 (node:crypto), fara dependinte.
// Ce NU face: certificate compuse (hibride clasic + PQ; sunt inca drafturi IETF), OCSP, constrangeri de nume, politici, liste delta sau
// partiale (IDP). Numele se compara pe octeti (DER identic), nu prin normalizarea completa din RFC 5280: lanturile emise aici folosesc
// aceeasi codificare.
// Revizuirea adversariala din 2026-09-25 a gasit sapte defecte, reparate aici si marcate in cod cu [A1]..[A10]: cheia pe disc sub
// PBKDF2 slab (A1), prima lista de revocare in loc de cea mai noua (A2), extensiile listei ignorate (A3), exceptie pe SPKI malformat si
// frunza cu cheie de 10 octeti admisa (A5), EKU pe autoritati neaplicat (A6), prima ancora cu acelasi nume la reinnoirea radacinii (A7),
// OID cu primul subidentificator pe mai multi octeti (A10, in der.mjs).
import crypto from 'node:crypto';
import * as D from './der.mjs';
export const ALG = {
'ml-dsa-44': '2.16.840.1.101.3.4.3.17',
'ml-dsa-65': '2.16.840.1.101.3.4.3.18',
'ml-dsa-87': '2.16.840.1.101.3.4.3.19',
};
const ALG_BY_OID = Object.fromEntries(Object.entries(ALG).map(([k, v]) => [v, k]));
/** [A5] lungimea cheii publice ML-DSA, FIPS 204 tabelul 2: o cheie de alta lungime nu e o cheie, oricare ar fi OID-ul de deasupra ei. */
export const PK_LEN = { 'ml-dsa-44': 1312, 'ml-dsa-65': 1952, 'ml-dsa-87': 2592 };
const OID = {
CN: '2.5.4.3', O: '2.5.4.10',
basicConstraints: '2.5.29.19', keyUsage: '2.5.29.15', extKeyUsage: '2.5.29.37', subjectAltName: '2.5.29.17',
subjectKeyIdentifier: '2.5.29.14', authorityKeyIdentifier: '2.5.29.35', crlNumber: '2.5.29.20',
issuingDistributionPoint: '2.5.29.28', deltaCRLIndicator: '2.5.29.27',
serverAuth: '1.3.6.1.5.5.7.3.1', clientAuth: '1.3.6.1.5.5.7.3.2',
};
const KU = { digitalSignature: 0, keyCertSign: 5, cRLSign: 6 };
const EXT_STIUTE = new Set([OID.basicConstraints, OID.keyUsage, OID.extKeyUsage, OID.subjectAltName, OID.subjectKeyIdentifier, OID.authorityKeyIdentifier]);
/** [A3] extensii de CRL pe care le intelegem (AKI, crlNumber) si extensii care schimba INTELESUL listei si pe care le refuzam oricum ar fi
* marcate: o lista delta sau una cu punct de distributie (partiala, indirecta, numai CA, numai anumite motive) nu acopera ce pare sa acopere. */
const CRL_EXT_STIUTE = new Set([OID.authorityKeyIdentifier, OID.crlNumber]);
const CRL_EXT_REFUZATE = { [OID.issuingDistributionPoint]: 'issuingDistributionPoint', [OID.deltaCRLIndicator]: 'deltaCRLIndicator' };
export class PkiError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
// ------------------------------------------------------------------------------------------------ chei
export function generateKey(alg) {
if (!ALG[alg]) throw new PkiError('ALG', 'unsupported algorithm ' + alg + ' (ml-dsa-44, ml-dsa-65, ml-dsa-87)');
return crypto.generateKeyPairSync(alg);
}
// [A1] Sigilarea proprie a cheii pe disc. PKCS#8 cifrat de Node (key.export cu cipher) foloseste PBKDF2 cu 2048 de iteratii, masurat la
// 3-5 ms per incercare de parola, adica o parola de 12 caractere se incearca de sute de ori pe secunda pe un singur fir. Aici cheia de
// cifrare vine din scrypt (N=2^17, r=8, p=1: 128 MiB de memorie si ~1 s per incercare pe un laptop, masurat in proba), iar DER-ul PKCS#8
// e cifrat cu AES-256-GCM, cu antetul (versiunea formatului si toti parametrii) legat ca AAD: un octet schimbat in antet sau in text
// strica eticheta de autentificare, deci "parola gresita" si "fisier atins" au acelasi raspuns, KEY_LOCKED.
// AerePqPkiKey ::= SEQUENCE { header SEQUENCE { version INTEGER (1), kdf UTF8String "scrypt", salt OCTET STRING (16), N INTEGER,
// r INTEGER, p INTEGER, cipher UTF8String "aes-256-gcm", iv OCTET STRING (12) }, tag OCTET STRING (16),
// ciphertext OCTET STRING (PKCS#8 DER cifrat) }
// scris PEM sub eticheta KEY_LABEL. PKCS#8 clasic (cifrat sau nu) NU mai e acceptat de pe disc.
export const KEY_LABEL = 'AERE PQ PKI PRIVATE KEY';
export const SEAL = Object.freeze({ version: 1, kdf: 'scrypt', N: 2 ** 17, r: 8, p: 1, cipher: 'aes-256-gcm', saltLen: 16, ivLen: 12, tagLen: 16, maxmem: 256 * 1024 * 1024 });
const SEAL_N_MIN = 2 ** 14, SEAL_N_MAX = 2 ** 20; // la citire: sub 2^14 e prea slab ca sa fi fost scris de noi, peste 2^20 ar cere peste 1 GiB
/** Parole pe care orice dictionar le incearca primele; comparate dupa ce se scot cifrele si semnele de la coada. */
const PAROLE_EVIDENTE = new Set(['password', 'passw0rd', 'passphrase', 'qwerty', 'qwertyuiop', '123456', '12345678', '123456789', '1234567890',
'iloveyou', 'letmein', 'welcome', 'admin', 'administrator', 'abc123', 'monkey', 'dragon', 'secret', 'changeme', 'default']);
/** [A1] Politica de parola: cel putin 12 caractere; cel putin 3 clase (minuscule, majuscule, cifre, altele) sau cel putin 20 de caractere;
* nu toate caracterele identice; nu o parola evidenta (cu sau fara cifre/semne la coada). Arunca PkiError('PASSPHRASE'). */
export function checkPassphrase(p) {
if (typeof p !== 'string' || p.length < 12) throw new PkiError('PASSPHRASE', 'the passphrase needs at least 12 characters');
const clase = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(p)).length;
if (clase < 3 && p.length < 20) throw new PkiError('PASSPHRASE', 'the passphrase needs 3 character classes (lower, upper, digit, other) or at least 20 characters');
if (/^(.)\1*$/s.test(p)) throw new PkiError('PASSPHRASE', 'a passphrase made of one repeated character is refused');
const nucleu = p.toLowerCase().replace(/[^a-z0-9]/g, '');
if (PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, ''))) throw new PkiError('PASSPHRASE', 'this passphrase is on every attacker\'s first list');
return p;
}
const sealHeader = (salt, N, r, p, iv) => D.seq(D.int(SEAL.version), D.utf8(SEAL.kdf), D.octets(salt), D.int(N), D.int(r), D.int(p), D.utf8(SEAL.cipher), D.octets(iv));
export function exportPrivateKey(key, passphrase) {
checkPassphrase(passphrase);
algOfKey(key);
const salt = crypto.randomBytes(SEAL.saltLen), iv = crypto.randomBytes(SEAL.ivLen);
const header = sealHeader(salt, SEAL.N, SEAL.r, SEAL.p, iv);
const kek = crypto.scryptSync(passphrase, salt, 32, { N: SEAL.N, r: SEAL.r, p: SEAL.p, maxmem: SEAL.maxmem });
const plain = key.export({ type: 'pkcs8', format: 'der' });
const c = crypto.createCipheriv(SEAL.cipher, kek, iv, { authTagLength: SEAL.tagLen });
c.setAAD(header);
const ct = Buffer.concat([c.update(plain), c.final()]);
const tag = c.getAuthTag();
plain.fill(0); kek.fill(0);
return pem(KEY_LABEL, D.seq(header, D.octets(tag), D.octets(ct)));
}
/** Citeste antetul unei chei sigilate fara sa o deschida (parametrii KDF, ca sa poata fi masurati). Arunca PkiError('KEY_FORMAT'). */
export function readSealedKeyHeader(pemText) {
const blobs = unpem(pemText, KEY_LABEL);
if (blobs.length !== 1) {
if (/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(pemText)) throw new PkiError('KEY_FORMAT', 'this is a PKCS#8 key; private keys are accepted only in the sealed "' + KEY_LABEL + '" format (scrypt + AES-256-GCM)');
throw new PkiError('KEY_FORMAT', 'expected exactly one "' + KEY_LABEL + '" block');
}
let h;
try {
const [hdr, tagEl, ctEl, ...rest] = D.children(D.expect(D.parse(blobs[0]), D.TAG.SEQUENCE, 'sealed key'));
if (rest.length || !ctEl) throw new Error('three parts expected');
const [ver, kdf, salt, N, r, p, cipher, iv, ...hr] = D.children(D.expect(hdr, D.TAG.SEQUENCE, 'header'));
if (hr.length || !iv) throw new Error('eight header fields expected');
h = {
version: Number(D.intToBigInt(ver)), kdf: D.expect(kdf, D.TAG.UTF8, 'kdf').content.toString('utf8'),
salt: Buffer.from(D.expect(salt, D.TAG.OCTET_STRING, 'salt').content), N: Number(D.intToBigInt(N)), r: Number(D.intToBigInt(r)), p: Number(D.intToBigInt(p)),
cipher: D.expect(cipher, D.TAG.UTF8, 'cipher').content.toString('utf8'), iv: Buffer.from(D.expect(iv, D.TAG.OCTET_STRING, 'iv').content),
tag: Buffer.from(D.expect(tagEl, D.TAG.OCTET_STRING, 'tag').content), ct: Buffer.from(D.expect(ctEl, D.TAG.OCTET_STRING, 'ciphertext').content), aad: Buffer.from(hdr.raw),
};
} catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: ' + e.message); }
if (h.version !== SEAL.version || h.kdf !== SEAL.kdf || h.cipher !== SEAL.cipher) throw new PkiError('KEY_FORMAT', `sealed key: unknown format (version ${h.version}, ${h.kdf}, ${h.cipher})`);
if (h.salt.length !== SEAL.saltLen || h.iv.length !== SEAL.ivLen || h.tag.length !== SEAL.tagLen) throw new PkiError('KEY_FORMAT', 'sealed key: salt, iv or tag of the wrong length');
if (!Number.isInteger(h.N) || h.N < SEAL_N_MIN || h.N > SEAL_N_MAX || (h.N & (h.N - 1)) !== 0 || !(h.r >= 1 && h.r <= 32) || !(h.p >= 1 && h.p <= 16)) throw new PkiError('KEY_FORMAT', `sealed key: scrypt parameters out of range (N=${h.N}, r=${h.r}, p=${h.p})`);
return h;
}
export function importPrivateKey(pemText, passphrase) {
const h = readSealedKeyHeader(pemText);
if (typeof passphrase !== 'string' || !passphrase) throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)');
let kek;
try { kek = crypto.scryptSync(passphrase, h.salt, 32, { N: h.N, r: h.r, p: h.p, maxmem: SEAL.maxmem }); }
catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: scrypt refused the parameters (' + e.message + ')'); }
let plain;
try {
const d = crypto.createDecipheriv(SEAL.cipher, kek, h.iv, { authTagLength: SEAL.tagLen });
d.setAAD(h.aad); d.setAuthTag(h.tag);
plain = Buffer.concat([d.update(h.ct), d.final()]);
} catch { throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)'); }
finally { kek.fill(0); }
try {
const key = crypto.createPrivateKey({ key: plain, format: 'der', type: 'pkcs8' });
algOfKey(key);
return key;
} catch (e) { throw new PkiError(e.code === 'ALG' ? 'ALG' : 'KEY_FORMAT', 'sealed key: the content is not an ML-DSA PKCS#8 key'); }
finally { plain.fill(0); }
}
function algOfKey(k) {
const t = k.asymmetricKeyType;
if (!ALG[t]) throw new PkiError('ALG', 'key type ' + t + ' is not ML-DSA');
return t;
}
// ------------------------------------------------------------------------------------------------ constructie
const algId = (alg) => D.seq(D.oid(ALG[alg])); // RFC 9881: parametrii LIPSESC
export function name({ cn, o }) {
const rdn = (oid, v) => D.set(D.seq(D.oid(oid), D.utf8(v)));
return D.seq(o ? rdn(OID.O, o) : null, rdn(OID.CN, cn));
}
function keyUsageBits(names) {
let v = 0, hi = -1;
for (const n of names) { const b = KU[n]; v |= 0x80 >> b; hi = Math.max(hi, b); }
const bytes = Buffer.from([v & 0xff]);
const unused = 7 - hi; // biti ramasi neinsemnati dupa cel mai mare bit pus (DER: fara biti zero la coada)
return D.bits(bytes, unused);
}
const ext = (oid, critical, valueDer) => D.seq(D.oid(oid), critical ? D.bool(true) : null, D.octets(valueDer));
function keyId(spkiDer) {
const spki = D.parse(spkiDer); const [, pk] = D.children(spki);
return crypto.createHash('sha256').update(D.bitString(pk).bytes).digest().subarray(0, 20); // RFC 7093 metoda 1
}
function randomSerial() {
const b = crypto.randomBytes(16);
b[0] &= 0x7f; if (b[0] === 0) b[0] = 1;
return b;
}
/**
* Emite un certificat. issuer = { cert (DER) , key (KeyObject privat) } sau null pentru o radacina auto-semnata.
* opts: { subject: {cn, o}, publicKey (KeyObject), ca: bool, pathLen?: int, days, dns?: [], ips?: [], eku?: ['serverAuth','clientAuth'], notBefore? }
*/
export function issue({ issuer, signingKey, subject, publicKey, ca = false, pathLen, days, dns = [], ips = [], eku = [], notBefore }) {
const alg = algOfKey(signingKey);
algOfKey(publicKey);
const spki = publicKey.export({ type: 'spki', format: 'der' });
const ski = keyId(spki);
const ic = issuer ? parseCert(issuer) : null;
const signerSpki = crypto.createPublicKey(signingKey).export({ type: 'spki', format: 'der' });
if (ic) {
if (!ic.isCA || !ic.keyUsage.has('keyCertSign')) throw new PkiError('ISSUER', 'the issuer certificate is not a CA with keyCertSign');
if (!signerSpki.equals(ic.spki)) throw new PkiError('ISSUER', 'the signing key does not belong to the issuer certificate');
if (!ic.ski) throw new PkiError('ISSUER', 'the issuer certificate has no subject key identifier');
} else if (!signerSpki.equals(spki)) throw new PkiError('ISSUER', 'a self-signed root is signed by its own key');
const issuerName = ic ? ic.subjectRaw : name(subject);
const aki = ic ? ic.ski : null;
const nb = notBefore ? new Date(notBefore) : new Date(Date.now() - 60 * 1000);
const na = new Date(nb.getTime() + days * 86400 * 1000);
const exts = [
ext(OID.basicConstraints, true, D.seq(ca ? D.bool(true) : null, ca && Number.isInteger(pathLen) ? D.int(pathLen) : null)),
ext(OID.keyUsage, true, keyUsageBits(ca ? ['digitalSignature', 'keyCertSign', 'cRLSign'] : ['digitalSignature'])),
eku.length ? ext(OID.extKeyUsage, false, D.seq(...eku.map((e) => D.oid(OID[e])))) : null,
dns.length || ips.length ? ext(OID.subjectAltName, false, D.seq(
...dns.map((d) => D.tlv(0x82, Buffer.from(d, 'ascii'))),
...ips.map((ip) => D.tlv(0x87, ipBytes(ip))))) : null,
ext(OID.subjectKeyIdentifier, false, D.octets(ski)),
aki ? ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, aki))) : null,
].filter(Boolean);
const tbs = D.seq(
D.ctx(0, D.int(2)), D.int(randomSerial()), algId(alg), issuerName,
D.seq(D.time(nb), D.time(na)), name(subject), spki,
D.ctx(3, D.seq(...exts)));
const sig = crypto.sign(null, tbs, signingKey);
return D.seq(tbs, algId(alg), D.bits(sig));
}
function ipBytes(ip) {
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some((x) => !(x >= 0 && x <= 255))) throw new PkiError('SAN', 'only IPv4 addresses are supported: ' + ip);
return Buffer.from(p);
}
/** Lista de revocare v2. revoked = [{ serial (Buffer sau hex), date }]; number = numarul CRL-ului (crescator). */
export function crl({ issuer, signingKey, revoked = [], days, number, thisUpdate }) {
const alg = algOfKey(signingKey);
const c = parseCert(issuer);
if (!c.isCA || !c.keyUsage.has('cRLSign')) throw new PkiError('ISSUER', 'the issuer may not sign revocation lists');
const tu = thisUpdate ? new Date(thisUpdate) : new Date(Date.now() - 60 * 1000);
const nu = new Date(tu.getTime() + days * 86400 * 1000);
const rev = revoked.map((r) => D.seq(D.int(Buffer.isBuffer(r.serial) ? r.serial : Buffer.from(r.serial, 'hex')), D.time(new Date(r.date))));
const tbs = D.seq(D.int(1), algId(alg), c.subjectRaw, D.time(tu), D.time(nu), rev.length ? D.seq(...rev) : null,
D.ctx(0, D.seq(ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, c.ski))), ext(OID.crlNumber, false, D.int(number)))));
return D.seq(tbs, algId(alg), D.bits(crypto.sign(null, tbs, signingKey)));
}
// ------------------------------------------------------------------------------------------------ citire
/** Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }, cu forma DER ceruta. */
function parseExtensions(seqEl, what) {
const exts = new Map();
for (const x of D.children(D.expect(seqEl, D.TAG.SEQUENCE, what))) {
const xs = D.children(D.expect(x, D.TAG.SEQUENCE, 'Extension'));
const id = D.oidToString(xs[0]);
let critical = false, val;
if (xs.length === 3) { if (xs[1].tag !== D.TAG.BOOLEAN || xs[1].content[0] !== 0xff) throw new PkiError('DER', 'critical must be TRUE when present (DER)'); critical = true; val = xs[2]; }
else if (xs.length === 2) val = xs[1];
else throw new PkiError('DER', 'an Extension has two or three parts');
if (exts.has(id)) throw new PkiError('EXT_DUP', 'extension ' + id + ' appears twice');
exts.set(id, { critical, value: D.expect(val, D.TAG.OCTET_STRING, 'extnValue').content });
}
return exts;
}
export function parseCert(der) {
const top = D.parse(der);
D.expect(top, D.TAG.SEQUENCE, 'certificat');
const [tbsEl, algEl, sigEl, ...rest] = D.children(top);
if (rest.length || !sigEl) throw new PkiError('DER', 'a certificate has exactly three parts');
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertificate'));
let i = 0;
if (!t[i] || t[i].tag !== 0xa0) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
const ver = D.intToBigInt(D.children(t[i++])[0]);
if (ver !== 2n) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
if (t.length < 7) throw new PkiError('DER', 'tbsCertificate is missing fields');
const serial = t[i++].content;
D.intToBigInt(t[i - 1]); // forma canonica si pozitiva (RFC 5280 4.1.2.2)
const innerAlg = sigAlgOf(t[i++]);
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
const [nbEl, naEl] = D.children(D.expect(t[i++], D.TAG.SEQUENCE, 'validity'));
if (!naEl) throw new PkiError('DER', 'validity has two times');
const subjectRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'subject').raw;
const spkiEl = D.expect(t[i++], D.TAG.SEQUENCE, 'subjectPublicKeyInfo');
// [A5] cheia publica se valideaza la citire: doua parti, BIT STRING fara biti nefolositi, si lungimea FIPS 204 a algoritmului declarat.
// Fara asta o frunza cu o "cheie" de 10 octeti sub id-ml-dsa-65 trecea verificarea lantului (verificatorul nu foloseste cheia frunzei),
// iar un intermediar cu aceeasi cheie arunca o exceptie din node:crypto in loc de un verdict.
const spkiKids = D.children(spkiEl);
if (spkiKids.length !== 2) throw new PkiError('SPKI', 'subjectPublicKeyInfo has two parts');
const pkAlg = sigAlgOf(spkiKids[0]);
const pkBits = D.bitString(spkiKids[1]);
if (pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg]) throw new PkiError('SPKI', `the ${pkAlg} public key must be ${PK_LEN[pkAlg]} bytes, this one has ${pkBits.bytes.length}`);
let exts = new Map();
while (i < t.length) {
const e = t[i++];
if (e.tag === 0xa1 || e.tag === 0xa2) throw new PkiError('UNIQUE_ID', 'unique identifiers are not accepted');
if (e.tag !== 0xa3 || exts.size) throw new PkiError('DER', 'unexpected field in tbsCertificate');
const inner = D.children(e);
if (inner.length !== 1) throw new PkiError('DER', 'extensions [3] wraps exactly one SEQUENCE');
exts = parseExtensions(inner[0], 'extensions');
}
const outerAlg = sigAlgOf(algEl);
if (outerAlg !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the certificate');
const sig = D.bitString(sigEl);
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
const c = {
der: Buffer.from(der), tbs: tbsEl.raw, alg: innerAlg, pkAlg, signature: sig.bytes, serial: Buffer.from(serial),
issuerRaw: Buffer.from(issuerRaw), subjectRaw: Buffer.from(subjectRaw), spki: Buffer.from(spkiEl.raw),
notBefore: D.timeToDate(nbEl), notAfter: D.timeToDate(naEl), exts,
isCA: false, pathLen: undefined, keyUsage: new Set(), eku: null, dns: [], ips: [], ski: null, aki: null, unknownCritical: [],
};
for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id)) c.unknownCritical.push(id);
const bc = exts.get(OID.basicConstraints);
if (bc) {
const f = D.children(D.parse(bc.value));
if (f[0] && f[0].tag === D.TAG.BOOLEAN) { c.isCA = f[0].content[0] === 0xff; if (!c.isCA) throw new PkiError('DER', 'cA FALSE must be absent (DER)'); f.shift(); }
if (f[0]) c.pathLen = Number(D.intToBigInt(f[0]));
}
const ku = exts.get(OID.keyUsage);
if (ku) {
const b = D.bitString(D.parse(ku.value));
for (const [n, bit] of Object.entries(KU)) if (b.bytes.length > (bit >> 3) && (b.bytes[bit >> 3] & (0x80 >> (bit & 7)))) c.keyUsage.add(n);
}
const ek = exts.get(OID.extKeyUsage);
if (ek) c.eku = new Set(D.children(D.parse(ek.value)).map((o) => D.oidToString(o)));
const san = exts.get(OID.subjectAltName);
if (san) for (const g of D.children(D.parse(san.value))) {
if (g.tag === 0x82) c.dns.push(g.content.toString('ascii').toLowerCase());
else if (g.tag === 0x87 && g.content.length === 4) c.ips.push([...g.content].join('.'));
}
const s = exts.get(OID.subjectKeyIdentifier);
if (s) c.ski = Buffer.from(D.expect(D.parse(s.value), D.TAG.OCTET_STRING, 'SKI').content);
const a = exts.get(OID.authorityKeyIdentifier);
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) c.aki = Buffer.from(k.content); }
return c;
}
function sigAlgOf(el) {
const f = D.children(D.expect(el, D.TAG.SEQUENCE, 'AlgorithmIdentifier'));
if (!f.length) throw new PkiError('DER', 'empty AlgorithmIdentifier');
const o = D.oidToString(f[0]);
if (!ALG_BY_OID[o]) throw new PkiError('ALG', 'algorithm ' + o + ' is not ML-DSA');
if (f.length !== 1) throw new PkiError('ALG_PARAMS', 'ML-DSA AlgorithmIdentifier must have no parameters (RFC 9881)');
return ALG_BY_OID[o];
}
const isTime = (el) => el && (el.tag === D.TAG.UTC_TIME || el.tag === D.TAG.GEN_TIME);
/**
* Citeste o lista de revocare v2. Intoarce { tbs, alg, signature, issuerRaw, thisUpdate, nextUpdate, revoked (Map serialHex -> Date),
* crlNumber (BigInt|null), aki (Buffer|null), refusedExt (string|null), exts }. [A3] Extensiile listei si ale intrarilor sunt citite:
* AKI si crlNumber sunt intelese; issuingDistributionPoint si deltaCRLIndicator sunt REFUZATE oricum ar fi marcate, si la fel orice alta
* extensie critica (a listei sau a unei intrari). Refuzul nu e exceptie: sta in refusedExt, si verifyChain il face verdict CRL_EXT, ca o
* lista straina cu o extensie rea sa nu opreasca verificarea unui lant pe care nu il priveste.
*/
export function parseCrl(der) {
const top = D.parse(der);
const [tbsEl, algEl, sigEl, ...rest] = D.children(D.expect(top, D.TAG.SEQUENCE, 'CRL'));
if (rest.length || !sigEl) throw new PkiError('DER', 'a revocation list has exactly three parts');
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertList'));
let i = 0;
if (!t[i] || t[i].tag !== D.TAG.INTEGER || D.intToBigInt(t[i++]) !== 1n) throw new PkiError('VERSION', 'only v2 revocation lists are accepted');
const innerAlg = sigAlgOf(t[i++]);
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
if (!isTime(t[i])) throw new PkiError('DER', 'thisUpdate missing');
const thisUpdate = D.timeToDate(t[i++]);
let nextUpdate = null;
if (isTime(t[i])) nextUpdate = D.timeToDate(t[i++]);
const revoked = new Map();
let refusedExt = null;
const refuza = (what) => { if (!refusedExt) refusedExt = what; };
if (t[i] && t[i].tag === D.TAG.SEQUENCE) {
for (const r of D.children(t[i++])) {
const [s, d, ee, ...er] = D.children(D.expect(r, D.TAG.SEQUENCE, 'revoked entry'));
if (!d || er.length) throw new PkiError('DER', 'a revoked entry has two or three parts');
D.intToBigInt(s); // seria, in forma canonica
if (!isTime(d)) throw new PkiError('DER', 'revocation date missing');
revoked.set(s.content.toString('hex'), D.timeToDate(d));
if (ee) for (const [id, x] of parseExtensions(ee, 'crlEntryExtensions')) if (x.critical) refuza(`a critical entry extension ${id}`); // reasonCode, invalidityDate: necritice, ignorate
}
}
let exts = new Map(), crlNumber = null, aki = null;
if (t[i] && t[i].tag === 0xa0) {
const inner = D.children(t[i++]);
if (inner.length !== 1) throw new PkiError('DER', 'crlExtensions [0] wraps exactly one SEQUENCE');
exts = parseExtensions(inner[0], 'crlExtensions');
for (const [id, x] of exts) {
if (CRL_EXT_REFUZATE[id]) refuza(`${CRL_EXT_REFUZATE[id]} (${id}); delta and partial lists are not supported`);
else if (x.critical && !CRL_EXT_STIUTE.has(id)) refuza(`an unknown critical extension ${id}`);
}
const n = exts.get(OID.crlNumber);
if (n) crlNumber = D.intToBigInt(D.parse(n.value));
const a = exts.get(OID.authorityKeyIdentifier);
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) aki = Buffer.from(k.content); }
}
if (i !== t.length) throw new PkiError('DER', 'unexpected field in tbsCertList');
if (sigAlgOf(algEl) !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the list');
const sig = D.bitString(sigEl);
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
return { tbs: tbsEl.raw, alg: innerAlg, signature: sig.bytes, issuerRaw: Buffer.from(issuerRaw), thisUpdate, nextUpdate, revoked, crlNumber, aki, refusedExt, exts };
}
// ------------------------------------------------------------------------------------------------ verificare
/** [A5] Un SPKI pe care node:crypto nu il poate importa da PkiError('SPKI'), pe care verifyChain o face verdict; niciodata exceptie bruta. */
function verifySig(tbs, signature, spkiDer, who) {
let key;
try { key = crypto.createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); }
catch { throw new PkiError('SPKI', `the public key of "${who}" cannot be decoded`); }
return crypto.verify(null, tbs, key, signature);
}
function hostMatches(pattern, host) {
if (pattern === host) return true;
if (pattern.startsWith('*.')) { const rest = pattern.slice(2); const dot = host.indexOf('.'); return dot > 0 && host.slice(dot + 1) === rest; }
return false;
}
const MAX_ADANCIME = 8, MAX_DRUMURI = 32;
const cmpBig = (a, b) => ((a ?? -1n) === (b ?? -1n) ? 0 : (a ?? -1n) > (b ?? -1n) ? 1 : -1);
/**
* Verifica un lant: leaf (DER), intermediates [DER], roots [DER] (increderea), at (Date), purpose ('serverAuth'|'clientAuth'|null),
* host (nume sau IPv4, pentru serverAuth), crls [DER], requireCrl (fiecare emitator din lant trebuie sa aiba o lista valabila).
* Intoarce { ok, code, reason, chain: [subject CN...] }. Intoarce intotdeauna un verdict: pe orice octeti, niciodata exceptie [A5].
*/
export function verifyChain(opts) {
try { return verifyChainInner(opts); }
catch (e) { return { ok: false, code: e instanceof PkiError ? e.code : 'DER', reason: String(e && e.message || e) }; }
}
function verifyChainInner({ leaf, intermediates = [], roots = [], at = new Date(), purpose = 'serverAuth', host = null, crls = [], requireCrl = false }) {
const fail = (code, reason) => ({ ok: false, code, reason });
let certs;
try { certs = { leaf: parseCert(leaf), inter: intermediates.map(parseCert), roots: roots.map(parseCert) }; }
catch (e) { return fail(e.code || 'DER', e.message); }
const lists = [];
try { for (const x of crls) lists.push(parseCrl(x)); } catch (e) { return fail(e.code || 'DER', 'revocation list: ' + e.message); }
const emite = (p, c) => p.subjectRaw.equals(c.issuerRaw) && (!c.aki || (p.ski && p.ski.equals(c.aki)));
// 1. drumurile: de la frunza in sus, emitatorul e certificatul al carui subiect e egal (pe octeti) cu emitentul si al carui SKI e AKI-ul.
// [A7] Se incearca TOATE candidatele (intai ancorele, apoi intermediarele, fara cicluri), si primul drum care trece intreg e raspunsul:
// la reinnoirea radacinii operatorul are doua ancore cu acelasi nume si aceeasi cheie, una expirata, si "prima gasita" refuza un lant
// valid; la fel cu un intermediar semnat incrucisat de o radacina straina, pus inaintea celui bun.
let blocat = certs.leaf, blocatAdancime = -1, incercate = 0; // cel mai adanc certificat fara niciun emitent candidat (pentru mesajul UNTRUSTED)
const stiva = [certs.leaf];
function* drumuri(cur) {
const ancore = certs.roots.filter((r) => emite(r, cur));
for (const r of ancore) yield [...stiva, r];
const inter = stiva.length < MAX_ADANCIME ? certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)) : [];
for (const c of inter) { stiva.push(c); yield* drumuri(c); stiva.pop(); }
if (!ancore.length && !inter.length && stiva.length - 1 > blocatAdancime) { blocat = cur; blocatAdancime = stiva.length - 1; }
}
let ultimul = null;
for (const path of drumuri(certs.leaf)) {
if (++incercate > MAX_DRUMURI) break;
const r = judeca(path);
if (r.ok) return r;
ultimul = r;
}
if (ultimul) return ultimul;
return fail('UNTRUSTED', 'no trusted issuer for "' + cnOf(blocat) + '"');
function judeca(path) {
const top = path[path.length - 1];
if (!top.subjectRaw.equals(top.issuerRaw) || !verifySig(top.tbs, top.signature, top.spki, cnOf(top))) return fail('ROOT', 'the trusted root is not a valid self-signed certificate');
// 2. fiecare legatura
for (let k = 0; k < path.length; k++) {
const c = path[k];
if (c.unknownCritical.length) return fail('CRITICAL_EXT', `"${cnOf(c)}" carries an unknown critical extension ${c.unknownCritical[0]}`);
if (at < c.notBefore) return fail('NOT_YET_VALID', `"${cnOf(c)}" is not valid before ${c.notBefore.toISOString()}`);
if (at > c.notAfter) return fail('EXPIRED', `"${cnOf(c)}" expired at ${c.notAfter.toISOString()}`);
if (k === path.length - 1) break;
const p = path[k + 1];
if (!p.isCA) return fail('NOT_CA', `"${cnOf(p)}" is not a CA (basicConstraints) and cannot issue "${cnOf(c)}"`);
if (!p.keyUsage.has('keyCertSign')) return fail('KEY_USAGE', `"${cnOf(p)}" has no keyCertSign`);
const casBelow = path.slice(1, k + 1).filter((x) => x.isCA).length; // CA-urile intermediare de sub p, fara frunza
if (p.pathLen !== undefined && casBelow > p.pathLen) return fail('PATH_LEN', `"${cnOf(p)}" allows ${p.pathLen} CA(s) below it, the chain has ${casBelow}`);
// [A6] EKU pe o autoritate restrange tot ce e sub ea (OpenSSL, Chrome, Mozilla fac la fel); anyExtendedKeyUsage NU scuteste, ca la OpenSSL.
if (purpose && p.eku && !p.eku.has(OID[purpose])) return fail('EKU', `the issuer "${cnOf(p)}" is not valid for ${purpose} (its extended key usage does not allow it)`);
if (!verifySig(c.tbs, c.signature, p.spki, cnOf(c))) return fail('SIGNATURE', `the signature on "${cnOf(c)}" does not verify under "${cnOf(p)}"`);
const rv = revocare(c, p);
if (rv) return rv;
}
// 4. frunza
const L = certs.leaf;
if (purpose) {
if (L.isCA) return fail('LEAF_IS_CA', 'a CA certificate is not accepted as an end-entity certificate');
if (!L.keyUsage.has('digitalSignature')) return fail('KEY_USAGE', 'the end-entity certificate has no digitalSignature');
if (L.eku && !L.eku.has(OID[purpose])) return fail('EKU', `the end-entity certificate is not valid for ${purpose}`);
}
if (host) {
const h = host.toLowerCase();
const ok = /^\d+\.\d+\.\d+\.\d+$/.test(h) ? L.ips.includes(h) : L.dns.some((d) => hostMatches(d, h));
if (!ok) return fail('HOSTNAME', `"${cnOf(L)}" is not valid for ${host}`);
}
return { ok: true, code: 'OK', reason: 'valid', chain: path.map(cnOf) };
}
// 3. revocarea lui c de catre emitatorul p. [A2] Dintre listele emitentului care VERIFICA sub cheia lui si nu sunt datate in viitor se
// ia cea cu thisUpdate cel mai nou (la egalitate, crlNumber cel mai mare), cum face si OpenSSL; "prima din intrare" lasa o revocare
// proaspata sa fie ascunsa de o lista veche pusa inaintea ei. [A3] O lista cu o extensie refuzata e verdict CRL_EXT, nu e sarita.
function revocare(c, p) {
const ale = lists.filter((l) => l.issuerRaw.equals(p.subjectRaw));
if (!ale.length) return requireCrl ? fail('CRL_MISSING', `no revocation list from "${cnOf(p)}"`) : null;
const verificate = ale.filter((l) => verifySig(l.tbs, l.signature, p.spki, cnOf(p)));
if (!verificate.length) return fail('CRL_SIGNATURE', `the revocation list of "${cnOf(p)}" does not verify`);
const rea = verificate.find((l) => l.refusedExt);
if (rea) return fail('CRL_EXT', `the revocation list of "${cnOf(p)}" carries ${rea.refusedExt}`);
const curente = verificate.filter((l) => at >= l.thisUpdate);
if (!curente.length) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is dated in the future`);
curente.sort((a, b) => (b.thisUpdate - a.thisUpdate) || cmpBig(b.crlNumber, a.crlNumber));
const list = curente[0];
if (list.nextUpdate && at > list.nextUpdate) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is not current (nextUpdate ${list.nextUpdate.toISOString()})`);
if (list.revoked.has(c.serial.toString('hex'))) return fail('REVOKED', `"${cnOf(c)}" is revoked by "${cnOf(p)}"`);
return null;
}
}
/** Semnatura unui certificat auto-semnat, verificata cu propria cheie (interoperabilitatea inversa: certificate facute de altii). */
export function selfSignatureValid(der) {
const c = parseCert(der);
return c.subjectRaw.equals(c.issuerRaw) && verifySig(c.tbs, c.signature, c.spki, cnOf(c));
}
export function cnOf(c) {
try {
for (const rdn of D.children(D.parse(c.subjectRaw))) for (const atv of D.children(rdn)) {
const [o, v] = D.children(atv);
if (D.oidToString(o) === OID.CN) return v.content.toString('utf8');
}
} catch { /* nume necitibil */ }
return '?';
}
export const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`;
export function unpem(text, label = 'CERTIFICATE') {
const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`, 'g');
const out = []; let m;
while ((m = re.exec(text))) out.push(Buffer.from(m[1].replace(/\s+/g, ''), 'base64'));
return out;
}

View File

@ -0,0 +1,99 @@
// Controlul negativ al probei PKI: fiecare paznic din pki.mjs / der.mjs se strica pe rand, la RULARE (conditie falsa, ca sa compileze),
// intr-o COPIE a modulului (pki.mjs, der.mjs, cli.mjs), iar proba, chemata cu PKI_MODULE pe copie, trebuie sa iasa ROSIE pe EXACT proba
// numita. O ancora care nu apare o singura data e un ESEC al controlului, nu o trecere (ancorele stau pe partea STABILA a randului).
// Martorul (proba pe original) ruleaza INTAI: din el se deriva numarul de probe pe care fiecare plantare trebuie sa il ruleze intreg
// (nu e scris fix: o constanta de ieri minte in ziua in care suita creste). La sfarsit originalul trebuie sa fie identic (sha256).
// Plantarile ruleaza cate PARALEL deodata (fiecare proba costa ~10 deschideri scrypt de ~0,7 s).
// node test/control-negativ.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const SRC = path.join(AICI, '..');
const PROBA = path.join(AICI, 'proba.mjs');
const FISIERE = ['pki.mjs', 'der.mjs', 'cli.mjs'];
const PARALEL = Math.max(1, Math.min(4, Number(process.env.PKI_PARALEL) || 4));
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
const inainte = Object.fromEntries(FISIERE.map((n) => [n, sha(path.join(SRC, n))]));
// [nume, fisier, ancora (exact o data), inlocuitor, prefixul probei care trebuie sa cada]
const PLANTARI = [
['semnatura oricum valida', 'pki.mjs', ' return crypto.verify(null, tbs, key, signature);', " return crypto.verify(null, tbs, key, signature) || Boolean(Number('1'));", 'un octet schimbat'],
['pathLen ignorat', 'pki.mjs', 'if (p.pathLen !== undefined && casBelow > p.pathLen)', "if (p.pathLen !== undefined && casBelow > p.pathLen && Boolean(Number('0')))", 'pathLen'],
['revocarea ignorata', 'pki.mjs', "if (list.revoked.has(c.serial.toString('hex')))", "if (list.revoked.has(c.serial.toString('hex')) && Boolean(Number('0')))", 'revocarea'],
['emitentul fara basicConstraints CA', 'pki.mjs', " if (!p.isCA) return fail('NOT_CA'", " if (!p.isCA && Boolean(Number('0'))) return fail('NOT_CA'", 'un certificat de entitate folosit ca emitent'],
['numele gazdei ignorat', 'pki.mjs', " if (!ok) return fail('HOSTNAME'", " if (!ok && Boolean(Number('0'))) return fail('HOSTNAME'", 'frunza pentru alt nume'],
['expirarea ignorata', 'pki.mjs', " if (at > c.notAfter) return fail('EXPIRED'", " if (at > c.notAfter && Boolean(Number('0'))) return fail('EXPIRED'", 'timpul'],
['parametri acceptati pe ML-DSA', 'pki.mjs', " if (f.length !== 1) throw new PkiError('ALG_PARAMS'", " if (f.length !== 1 && Boolean(Number('0'))) throw new PkiError('ALG_PARAMS'", 'forma'],
['extensia critica necunoscuta ignorata', 'pki.mjs', ' for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id))', " for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id) && Boolean(Number('0')))", 'extensie critica necunoscuta'],
['scopul (EKU) pe frunza ignorat', 'pki.mjs', ' if (L.eku && !L.eku.has(OID[purpose]))', " if (L.eku && !L.eku.has(OID[purpose]) && Boolean(Number('0')))", 'scopul'],
['cheia privata scrisa in clar (PKCS#8)', 'pki.mjs', ' return pem(KEY_LABEL, D.seq(header, D.octets(tag), D.octets(ct)));', " return key.export({ type: 'pkcs8', format: 'pem' });", 'linia de comanda'],
['DER necanonic acceptat', 'der.mjs', " if (l < 0x80) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');", " if (l < 0x80 && Boolean(Number('0'))) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');", 'forma'],
// revizuirea din 2026-09-25
['[A1] scrypt coborat la N=2^10', 'pki.mjs', 'N: 2 ** 17,', 'N: 2 ** 10,', 'sigilarea cheii'],
['[A1] politica de parola redusa la 12 caractere (accepta aaaaaaaaaaaa)', 'pki.mjs', 'export function checkPassphrase(p) {', "export function checkPassphrase(p) { if (typeof p === 'string' && p.length >= 12 && Boolean(Number('1'))) return p;", 'politica de parola'],
['[A1] parolele evidente acceptate', 'pki.mjs', " if (PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, '')))", " if ((PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, ''))) && Boolean(Number('0')))", 'politica de parola'],
['[A2] prima lista din intrare in loc de cea mai noua', 'pki.mjs', ' curente.sort((a, b) => (b.thisUpdate - a.thisUpdate) || cmpBig(b.crlNumber, a.crlNumber));', ' curente.sort(() => 0);', 'cea mai noua lista'],
['[A3] extensiile listei ignorate', 'pki.mjs', ' const rea = verificate.find((l) => l.refusedExt);', " const rea = verificate.find((l) => l.refusedExt && Boolean(Number('0')));", 'extensiile listei'],
['[A5] lungimea cheii publice neverificata', 'pki.mjs', ' if (pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg])', " if ((pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg]) && Boolean(Number('0')))", 'cheie publica malformata'],
['[A6] EKU pe autoritati ignorat', 'pki.mjs', ' if (purpose && p.eku && !p.eku.has(OID[purpose]))', " if (purpose && p.eku && !p.eku.has(OID[purpose]) && Boolean(Number('0')))", 'EKU pe autoritati'],
['[A7] numai prima ancora candidata', 'pki.mjs', ' const ancore = certs.roots.filter((r) => emite(r, cur));', ' const ancore = certs.roots.filter((r) => emite(r, cur)).slice(0, 1);', 'reinnoirea radacinii'],
['[A7] numai primul intermediar candidat', 'pki.mjs', 'certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)) : [];', 'certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)).slice(0, 1) : [];', 'reinnoirea radacinii'],
['[A10] primul subidentificator OID scris pe un singur octet', 'der.mjs', ' const out = base128(p[0] * 40n + p[1]);', ' const out = [Number((p[0] * 40n + p[1]) & 0x7fn)];', 'OID'],
['[A10] arcul OID dedus fara regula de 80', 'der.mjs', ' const arc = first < 80n ? first / 40n : 2n;', ' const arc = first / 40n;', 'OID'],
];
// 1. martorul: originalul trebuie sa fie verde, si din el se ia numarul de probe
const martorJson = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-martor-')), 'rez.json');
const martor = spawnSync(process.execPath, [PROBA], { encoding: 'utf8', env: { ...process.env, PKI_JSON: martorJson }, timeout: 600000 });
if (!fs.existsSync(martorJson)) { console.log('STRICAT: martorul nu a scris rezultatul (cod ' + martor.status + ')\n' + (martor.stdout || '').slice(-600)); process.exit(2); }
const rezMartor = JSON.parse(fs.readFileSync(martorJson, 'utf8'));
const N = rezMartor.length, verde = martor.status === 0 && rezMartor.every((x) => x.ok);
console.log(`martorul: ${rezMartor.filter((x) => x.ok).length}/${N} probe trecute pe original${verde ? '' : ' (NU e verde: ' + rezMartor.filter((x) => !x.ok).map((x) => x.nume.slice(0, 40)).join(' | ') + ')'}`);
for (const [, , , , tinta] of PLANTARI) if (!rezMartor.some((x) => x.nume.startsWith(tinta))) console.log(` ESEC tinta "${tinta}..." nu exista printre probele martorului`);
fs.rmSync(path.dirname(martorJson), { recursive: true, force: true });
// 2. plantarile, cate PARALEL deodata
function planteaza([nume, fis, vechi, nou, tinta]) {
return new Promise((gata) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-plantat-'));
const sfarsit = (linie, ok) => { fs.rmSync(dir, { recursive: true, force: true }); gata({ nume, linie, ok }); };
for (const n of FISIERE) fs.copyFileSync(path.join(SRC, n), path.join(dir, n));
const t = fs.readFileSync(path.join(dir, fis), 'utf8');
const ap = t.split(vechi).length - 1;
if (ap !== 1) return sfarsit(` ESEC ${nume}: ancora apare de ${ap} ori (cerut 1), plantarea nu s-a pus`, false);
fs.writeFileSync(path.join(dir, fis), t.replace(vechi, nou));
const out = path.join(dir, 'rez.json');
const p = spawn(process.execPath, [PROBA], { env: { ...process.env, PKI_MODULE: path.join(dir, 'pki.mjs'), PKI_JSON: out }, stdio: ['ignore', 'pipe', 'pipe'] });
let log = ''; p.stdout.on('data', (d) => { log += d; }); p.stderr.on('data', (d) => { log += d; });
const timer = setTimeout(() => p.kill(), 600000);
p.on('close', (cod) => {
clearTimeout(timer);
if (!fs.existsSync(out)) return sfarsit(` STRICAT ${nume}: proba nu a scris rezultatul (cod ${cod}) ${log.slice(-200).replace(/\s+/g, ' ')}`, false);
const rez = JSON.parse(fs.readFileSync(out, 'utf8'));
const rosii = rez.filter((x) => !x.ok).map((x) => x.nume);
const prins = rosii.some((n) => n.startsWith(tinta));
if (rez.length !== N) return sfarsit(` STRICAT ${nume}: au rulat ${rez.length} probe din ${N}`, false);
if (prins) return sfarsit(` PRINS ${nume}: ${rosii.length} rosii, intre ele "${tinta}..."`, true);
return sfarsit(` NEPRINS ${nume}: proba "${tinta}..." a ramas verde (rosii: ${rosii.map((r) => r.slice(0, 40)).join(' | ').slice(0, 200)})`, false);
});
});
}
const rezultate = new Array(PLANTARI.length);
let urm = 0;
async function lucrator() { while (urm < PLANTARI.length) { const k = urm++; rezultate[k] = await planteaza(PLANTARI[k]); process.stdout.write(`\r ${rezultate.filter(Boolean).length}/${PLANTARI.length} plantari rulate`); } }
await Promise.all(Array.from({ length: PARALEL }, lucrator));
process.stdout.write('\r');
for (const r of rezultate) console.log(r.linie);
const bune = rezultate.filter((r) => r.ok).length, rele = rezultate.length - bune;
// 3. originalul e neatins
const dupa = Object.fromEntries(FISIERE.map((n) => [n, sha(path.join(SRC, n))]));
const neatins = JSON.stringify(inainte) === JSON.stringify(dupa);
const tinteOk = PLANTARI.every(([, , , , tinta]) => rezMartor.some((x) => x.nume.startsWith(tinta)));
console.log(`\ncontrol negativ: ${bune}/${PLANTARI.length} plantari prinse; originalul ${neatins ? 'identic' : 'SCHIMBAT'}; martorul ${verde ? `verde ${N}/${N}` : 'NU e verde'}`);
process.exitCode = bune === PLANTARI.length && rele === 0 && neatins && verde && tinteOk ? 0 : 1;

417
pq-pki/test/proba.mjs Normal file
View File

@ -0,0 +1,417 @@
// Proba autoritatii de certificare post-cuantice (pki.mjs, cli.mjs, der.mjs). Fiecare verdict al verificatorului nostru e comparat cu cel al
// unei implementari STRAINE, OpenSSL 3.5 in linia de comanda (verificarea lui de lant, cu -x509_strict), iar TLS-ul e un handshake
// real: server Node cu lantul nostru, client OpenSSL si client Node. Fiecare refuz cere MOTIVUL (codul), nu doar refuzul.
// Cere `openssl` 3.5+ in PATH (Git for Windows il are); fara el proba iese STRICAT, nu verde.
// node test/proba.mjs (PKI_MODULE=<cale catre pki.mjs> o ruleaza pe o copie; asa o cheama controlul negativ)
// Iesire: 0 toate trec, 1 cel putin una cade, 2 STRICAT (unealta straina lipseste).
// Probele marcate [A1]..[A10] sunt regresiile celor sapte defecte ale revizuirii adversariale din 2026-09-25.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import tls from 'node:tls';
import crypto from 'node:crypto';
import { spawnSync, spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const MOD = process.env.PKI_MODULE ? path.resolve(process.env.PKI_MODULE) : path.join(AICI, '..', 'pki.mjs');
const P = await import(pathToFileURL(MOD).href);
const Dm = await import(pathToFileURL(path.join(path.dirname(MOD), 'der.mjs')).href);
const CLI = path.join(path.dirname(MOD), 'cli.mjs');
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-'));
const f = (n) => path.join(T, n);
const rez = [];
async function test(nume, fn) {
try { await fn(); rez.push({ nume, ok: true }); console.log(' OK ' + nume); }
catch (e) { rez.push({ nume, ok: false, motiv: String(e.message || e) }); console.log(' ESEC ' + nume + ' -> ' + String(e.message || e).slice(0, 300)); }
}
const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
const has = (s, sub, m) => { if (!String(s).includes(sub)) throw new Error(`${m}: lipseste "${sub}" in ${String(s).slice(0, 300)}`); };
/** fn trebuie sa arunce PkiError cu codul cerut. */
const code = (fn, c, m) => { let e = null; try { fn(); } catch (x) { e = x; } if (!e) throw new Error(m + ': nu a aruncat, asteptat ' + c); if (e.code !== c) throw new Error(`${m}: cod ${e.code} (${e.message}), asteptat ${c}`); };
const env = { ...process.env, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' };
function ossl(args, input) {
const r = spawnSync('openssl', args, { encoding: 'utf8', env, input: input ?? '', timeout: 30000 });
return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), err: r.error };
}
// AERE-SINTETIC: parola de proba, valabila numai pentru cheile generate in aceasta rulare, intr-un dosar temporar sters la sfarsit
const PASS_PROBA = 'proba-parola-lunga-1';
const penv = { ...env, AERE_PKI_PASSPHRASE: PASS_PROBA };
const run = (a, e = penv) => { const r = spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', env: e, timeout: 120000 }); return { code: r.status, out: (r.stdout || '') + (r.stderr || '') }; };
// ------------------------------------------------------------------------------------------------ OpenSSL de fata
const v = ossl(['version']);
if (v.err || !/OpenSSL 3\.(5|6|7|8|9)/.test(v.out)) { console.log('STRICAT: nu exista OpenSSL 3.5+ in PATH (' + (v.err ? v.err.message : v.out.trim()) + ')'); process.exit(2); }
console.log('unealta straina: ' + v.out.trim());
// ------------------------------------------------------------------------------------------------ lantul de baza
const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 3650 });
const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 30, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] });
const w = (n, der, lbl = 'CERTIFICATE') => { fs.writeFileSync(f(n), P.pem(lbl, der)); return f(n); };
w('root.pem', root); w('inter.pem', inter); w('leaf.pem', leaf);
fs.writeFileSync(f('leaf.key'), kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
fs.writeFileSync(f('inter.key'), ki.privateKey.export({ type: 'pkcs8', format: 'pem' })); // pentru `openssl ca -gencrl` (liste facute de unealta straina)
const V = (o) => P.verifyChain({ leaf, intermediates: [inter], roots: [root], host: 'localhost', ...o });
const osslVerify = (extra, leafFile = f('leaf.pem'), chain = [f('inter.pem')]) =>
ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), ...chain.flatMap((c) => ['-untrusted', c]), ...extra, leafFile]);
/** O lista de revocare a intermediarului facuta de OpenSSL (`openssl ca -gencrl`), cu extensiile din crlExt si intrarile din index. */
function gencrl(crlExt, outName, index = '') {
fs.writeFileSync(f('ca.index'), index); fs.writeFileSync(f('ca.crlnumber'), '05\n');
fs.writeFileSync(f('ca.cnf'), `[ ca ]\ndefault_ca = CA_default\n[ CA_default ]\ndir = ${T.replace(/\\/g, '/')}\ndatabase = $dir/ca.index\ncrlnumber = $dir/ca.crlnumber\n` +
`default_crl_days = 7\ndefault_md = default\ncrl_extensions = crl_ext\n[ crl_ext ]\n${crlExt}\n[ idp ]\nfullname = URI:http://crl.example/issuing.crl\n`);
const r = ossl(['ca', '-config', f('ca.cnf'), '-gencrl', '-keyfile', f('inter.key'), '-cert', f('inter.pem'), '-out', f(outName)]);
if (r.code !== 0) throw new Error('openssl ca -gencrl: ' + r.out.slice(0, 300));
return P.unpem(fs.readFileSync(f(outName), 'utf8'), 'X509 CRL')[0];
}
const utc = (d) => d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'; // YYMMDDHHMMSSZ pentru index.txt
await test('lantul ML-DSA-87 / ML-DSA-65 / ML-DSA-65: valid la noi SI la OpenSSL (-x509_strict, sslserver)', () => {
const r = V({}); eq(r.ok, true, 'noi ' + r.reason); eq(r.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'lantul');
const o = osslVerify([]); eq(o.code, 0, 'openssl ' + o.out); has(o.out, ': OK', 'openssl');
});
await test('OpenSSL citeste certificatele: algoritmii ML-DSA, cheile, SAN, KeyUsage critic, CA:FALSE pe frunza', () => {
const t = ossl(['x509', '-in', f('leaf.pem'), '-noout', '-text']).out;
has(t, 'Signature Algorithm: ML-DSA-65', 'semnatura'); has(t, 'Public Key Algorithm: ML-DSA-65', 'cheia');
has(t, 'DNS:localhost, IP Address:127.0.0.1', 'SAN'); has(t, 'X509v3 Key Usage: critical', 'KU'); has(t, 'CA:FALSE', 'bc');
has(ossl(['x509', '-in', f('root.pem'), '-noout', '-text']).out, 'Signature Algorithm: ML-DSA-87', 'radacina');
});
await test('interoperabilitatea inversa: un certificat ML-DSA facut de OpenSSL e citit si verificat de noi; unul stricat nu', () => {
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('o.key'), '-out', f('o.pem'), '-days', '2', '-nodes', '-subj', '/CN=openssl-made']);
eq(o.code, 0, 'openssl req ' + o.out);
const der = P.unpem(fs.readFileSync(f('o.pem'), 'utf8'))[0];
eq(P.selfSignatureValid(der), true, 'semnatura OpenSSL la noi'); eq(P.parseCert(der).alg, 'ml-dsa-44', 'algoritmul');
const bad = Buffer.from(der); bad[bad.length - 5] ^= 1; eq(P.selfSignatureValid(bad), false, 'semnatura stricata');
});
// ------------------------------------------------------------------------------------------------ revocarea
const crlRev = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [{ serial: P.parseCert(leaf).serial, date: new Date() }], days: 7, number: 2 });
const crlOk = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [], days: 7, number: 1 });
const crlRoot = P.crl({ issuer: root, signingKey: kr.privateKey, revoked: [], days: 7, number: 1 });
w('crl-rev.pem', crlRev, 'X509 CRL'); w('crl-ok.pem', crlOk, 'X509 CRL'); w('crl-root.pem', crlRoot, 'X509 CRL');
await test('revocarea: frunza revocata e REFUZATA (REVOKED) la noi si "certificate revoked" la OpenSSL; nerevocata trece la amandoi', () => {
const r = V({ crls: [crlRev] }); eq(r.code, 'REVOKED', 'noi');
const o = osslVerify(['-crl_check_all', '-CRLfile', f('crl-rev.pem'), '-CRLfile', f('crl-root.pem')]); has(o.out, 'certificate revoked', 'openssl'); eq(o.code === 0, false, 'openssl cod');
eq(V({ crls: [crlOk, crlRoot], requireCrl: true }).ok, true, 'noi, nerevocata');
eq(osslVerify(['-crl_check_all', '-CRLfile', f('crl-ok.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl, nerevocata');
});
await test('lista ceruta si lipsa: CRL_MISSING; lista semnata de alta cheie: CRL_SIGNATURE; lista expirata: CRL_STALE', () => {
eq(V({ crls: [crlOk], requireCrl: true }).code, 'CRL_MISSING', 'radacina fara lista');
const altCa = P.generateKey('ml-dsa-65');
const fals = P.issue({ issuer: null, signingKey: altCa.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: altCa.publicKey, ca: true, days: 10 });
const crlFals = P.crl({ issuer: fals, signingKey: altCa.privateKey, revoked: [], days: 7, number: 9 });
eq(V({ crls: [crlFals] }).code, 'CRL_SIGNATURE', 'lista unei chei straine cu acelasi nume');
eq(V({ crls: [crlOk], at: new Date(Date.now() + 9 * 86400000) }).code, 'CRL_STALE', 'lista dupa nextUpdate');
});
// [A2] prima lista din intrare nu e lista; e cea mai noua dintre cele care verifica
await test('cea mai noua lista castiga [A2]: [veche goala, noua cu seria] in ambele ordini: REVOKED la noi si "certificate revoked" la OpenSSL; [veche cu seria, noua goala]: OK la amandoi; thisUpdate egal: crlNumber mai mare; o lista datata in viitor nu conteaza', () => {
const serial = P.parseCert(leaf).serial;
const L = (revoked, number, thisUpdate) => P.crl({ issuer: inter, signingKey: ki.privateKey, revoked, days: 7, number, thisUpdate });
const veche = L([], 1, new Date(Date.now() - 3600000)), noua = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 60000));
eq(V({ crls: [veche, noua] }).code, 'REVOKED', 'veche intai'); eq(V({ crls: [noua, veche] }).code, 'REVOKED', 'noua intai');
w('c-veche.pem', veche, 'X509 CRL'); w('c-noua.pem', noua, 'X509 CRL');
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-veche.pem'), '-CRLfile', f('c-noua.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl veche intai');
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-noua.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl noua intai');
const vecheCuSeria = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 7200000)), nouaGoala = L([], 3, new Date(Date.now() - 30000));
const r = V({ crls: [vecheCuSeria, nouaGoala] }); eq(r.ok, true, 'noua goala castiga ' + r.reason);
w('c-vs.pem', vecheCuSeria, 'X509 CRL'); w('c-ng.pem', nouaGoala, 'X509 CRL');
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-vs.pem'), '-CRLfile', f('c-ng.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl noua goala castiga');
const t = new Date(Date.now() - 120000);
const n1 = L([], 1, t), n2 = L([{ serial, date: new Date() }], 2, t);
eq(V({ crls: [n1, n2] }).code, 'REVOKED', 'egalitate: #2 castiga'); eq(V({ crls: [n2, n1] }).code, 'REVOKED', 'egalitate: #2 castiga, invers');
const viitor = L([{ serial, date: new Date() }], 9, new Date(Date.now() + 3600000));
eq(V({ crls: [viitor, veche] }).ok, true, 'lista din viitor nu conteaza langa una curenta'); eq(V({ crls: [viitor] }).code, 'CRL_STALE', 'singura lista e din viitor');
w('c-viitor.pem', viitor, 'X509 CRL');
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl: viitor + curenta = OK');
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('crl-root.pem')]).code === 0, false, 'openssl: numai viitor = refuz');
});
// [A3] extensiile listei
await test('extensiile listei [A3]: delta (deltaCRLIndicator, critic sau nu), IDP critic (lista OpenSSL) si o extensie critica necunoscuta (lista OpenSSL) dau CRL_EXT cu numele ei; la OpenSSL "different CRL scope", "unhandled critical CRL extension", delta refuzat cu -extended_crl; necritica necunoscuta ignorata la amandoi; intrare cu extensie critica refuzata', () => {
const c = P.parseCert(inter); const alg = Dm.seq(Dm.oid(P.ALG['ml-dsa-65']));
const ex = (oid, critical, val) => Dm.seq(Dm.oid(oid), critical ? Dm.bool(true) : null, Dm.octets(val));
const aki = ex('2.5.29.35', false, Dm.seq(Dm.tlv(0x80, c.ski))), num = ex('2.5.29.20', false, Dm.int(3));
const lista = (exts, rev = null) => { const tbs = Dm.seq(Dm.int(1), alg, c.subjectRaw, Dm.time(new Date(Date.now() - 60000)), Dm.time(new Date(Date.now() + 7 * 86400000)), rev, Dm.ctx(0, Dm.seq(...exts))); return Dm.seq(tbs, alg, Dm.bits(crypto.sign(null, tbs, ki.privateKey))); };
const delta = lista([aki, num, ex('2.5.29.27', true, Dm.int(1))]), deltaNecritic = lista([aki, num, ex('2.5.29.27', false, Dm.int(1))]);
const r1 = V({ crls: [delta] }); eq(r1.code, 'CRL_EXT', 'delta critic'); has(r1.reason, 'deltaCRLIndicator', 'motivul delta');
eq(V({ crls: [deltaNecritic] }).code, 'CRL_EXT', 'delta necritic, tot refuzat');
has(P.parseCrl(delta).refusedExt, 'delta', 'parseCrl il vede fara sa arunce');
w('c-delta.pem', delta, 'X509 CRL');
has(osslVerify(['-crl_check', '-extended_crl', '-CRLfile', f('c-delta.pem')]).out, 'unable to get certificate CRL', 'openssl -extended_crl nu foloseste un delta');
const idp = gencrl('authorityKeyIdentifier = keyid\nissuingDistributionPoint = critical, @idp', 'c-idp.crl');
const r2 = V({ crls: [idp] }); eq(r2.code, 'CRL_EXT', 'IDP'); has(r2.reason, 'issuingDistributionPoint', 'motivul IDP');
has(osslVerify(['-crl_check', '-CRLfile', f('c-idp.crl')]).out, 'different CRL scope', 'openssl IDP');
const crit = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = critical, ASN1:UTF8String:x', 'c-crit.crl');
const r3 = V({ crls: [crit] }); eq(r3.code, 'CRL_EXT', 'critica necunoscuta'); has(r3.reason, '1.2.3.4.5', 'motivul critica');
has(osslVerify(['-crl_check', '-CRLfile', f('c-crit.crl')]).out, 'unhandled critical CRL extension', 'openssl critica');
const nec = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = ASN1:UTF8String:x', 'c-nec.crl');
const r4 = V({ crls: [nec], requireCrl: false }); eq(r4.ok, true, 'necritica necunoscuta ignorata ' + r4.reason);
eq(osslVerify(['-crl_check', '-CRLfile', f('c-nec.crl')]).code, 0, 'openssl necritica');
const intrare = Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.29', true, Dm.seq(Dm.tlv(0xa4, c.subjectRaw)))))); // certificateIssuer critic (lista indirecta)
const r5 = V({ crls: [lista([aki, num], intrare)] }); eq(r5.code, 'CRL_EXT', 'intrare cu extensie critica'); has(r5.reason, 'entry extension', 'motivul intrarii');
const r6 = V({ crls: [lista([aki, num], Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.21', false, Dm.tlv(0x0a, Buffer.from([1])))))))] });
eq(r6.code, 'REVOKED', 'intrare cu reasonCode necritic: seria conteaza');
});
await test('lista facuta de OpenSSL (openssl ca -gencrl, AKI + crlNumber, reasonCode pe intrare): OK la noi cand e goala, REVOKED cand poarta seria frunzei; AKI si crlNumber citite', () => {
const goala = gencrl('authorityKeyIdentifier = keyid', 'o-goala.crl');
const r = V({ crls: [goala] }); eq(r.ok, true, 'goala ' + r.reason);
const pc = P.parseCrl(goala); eq(pc.crlNumber, 5n, 'crlNumber'); eq(pc.aki && pc.aki.equals(P.parseCert(inter).ski), true, 'AKI = SKI-ul emitentului'); eq(pc.refusedExt, null, 'nimic refuzat');
const serial = P.parseCert(leaf).serial.toString('hex').toUpperCase();
const rev = gencrl('authorityKeyIdentifier = keyid', 'o-rev.crl', `R\t${utc(new Date(Date.now() + 30 * 86400000))}\t${utc(new Date())},keyCompromise\t${serial}\tunknown\t/CN=localhost\n`);
eq(V({ crls: [rev] }).code, 'REVOKED', 'revocata de lista OpenSSL'); eq(P.parseCrl(rev).revoked.size, 1, 'o intrare');
});
// ------------------------------------------------------------------------------------------------ refuzuri, cu perechea OpenSSL
await test('un octet schimbat in frunza: SIGNATURE la noi, refuz la OpenSSL', () => {
const p = P.parseCert(leaf); const bad = Buffer.from(leaf); const o = bad.indexOf(Buffer.from('localhost'), 0); bad[o] = 0x4c; // "Localhost"
eq(P.verifyChain({ leaf: bad, intermediates: [inter], roots: [root] }).code, 'SIGNATURE', 'noi');
eq(osslVerify([], w('bad.pem', bad)).code === 0, false, 'openssl'); eq(p.serial.length > 0, true, 'seria');
});
await test('alta radacina: UNTRUSTED la noi, "unable to get local issuer" la OpenSSL', () => {
const k = P.generateKey('ml-dsa-65');
const alt = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: 'Alt Root' }, publicKey: k.publicKey, ca: true, days: 10 });
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [alt], host: 'localhost' }); eq(r.code, 'UNTRUSTED', 'noi'); has(r.reason, 'Proba Issuing', 'numeste certificatul ramas fara emitent');
const o = ossl(['verify', '-x509_strict', '-CAfile', w('alt.pem', alt), '-untrusted', f('inter.pem'), f('leaf.pem')]); has(o.out, 'unable to get local issuer', 'openssl');
});
await test('timpul: EXPIRED dupa notAfter si NOT_YET_VALID inainte de notBefore, la noi si la OpenSSL (-attime)', () => {
const tarziu = new Date(Date.now() + 40 * 86400000), devreme = new Date(Date.now() - 86400000);
eq(V({ at: tarziu }).code, 'EXPIRED', 'noi tarziu'); eq(V({ at: devreme }).code, 'NOT_YET_VALID', 'noi devreme');
has(osslVerify(['-attime', String(Math.floor(tarziu / 1000))]).out, 'certificate has expired', 'openssl tarziu');
has(osslVerify(['-attime', String(Math.floor(devreme / 1000))]).out, 'not yet valid', 'openssl devreme');
});
await test('pathLen: o autoritate cu pathLen 0 care are sub ea inca o autoritate: PATH_LEN la noi, refuz la OpenSSL', () => {
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
const sub = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'Sub CA' }, publicKey: k2.publicKey, ca: true, days: 30 });
const l2 = P.issue({ issuer: sub, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
eq(P.verifyChain({ leaf: l2, intermediates: [sub, inter], roots: [root], host: 'localhost' }).code, 'PATH_LEN', 'noi');
const o = osslVerify([], w('l2.pem', l2), [w('sub.pem', sub), f('inter.pem')]); eq(o.code === 0, false, 'openssl ' + o.out); has(o.out, 'path length', 'openssl motivul');
});
await test('un certificat de entitate folosit ca emitent: NOT_CA la noi, "invalid CA certificate" la OpenSSL (lant facut de OpenSSL)', () => {
// frunza noastra nu poate semna: o face OpenSSL, care semneaza cu orice certificat
let o = ossl(['req', '-new', '-newkey', 'mldsa65', '-keyout', f('x.key'), '-out', f('x.csr'), '-nodes', '-subj', '/CN=victim']);
eq(o.code, 0, 'csr ' + o.out);
o = ossl(['x509', '-req', '-in', f('x.csr'), '-CA', f('leaf.pem'), '-CAkey', f('leaf.key'), '-out', f('x.pem'), '-days', '2', '-set_serial', '7']);
eq(o.code, 0, 'semnat de frunza ' + o.out);
const x = P.unpem(fs.readFileSync(f('x.pem'), 'utf8'))[0];
const r = P.verifyChain({ leaf: x, intermediates: [leaf, inter], roots: [root], purpose: null });
eq(r.code, 'NOT_CA', 'noi ' + r.reason);
const ov = ossl(['verify', '-CAfile', f('root.pem'), '-untrusted', f('inter.pem'), '-untrusted', f('leaf.pem'), f('x.pem')]);
eq(ov.code === 0, false, 'openssl ' + ov.out);
});
await test('frunza pentru alt nume: HOSTNAME la noi, "hostname mismatch" la OpenSSL; IP-ul din SAN trece la amandoi', () => {
eq(V({ host: 'aere.example' }).code, 'HOSTNAME', 'noi'); has(osslVerify(['-verify_hostname', 'aere.example']).out, 'hostname mismatch', 'openssl');
eq(V({ host: '127.0.0.1' }).ok, true, 'noi IP'); eq(osslVerify(['-verify_ip', '127.0.0.1']).code, 0, 'openssl IP');
});
await test('scopul: o frunza numai clientAuth folosita ca server: EKU la noi, "unsuitable certificate purpose" la OpenSSL', () => {
const k = P.generateKey('ml-dsa-65');
const cl = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'client' }, publicKey: k.publicKey, days: 30, eku: ['clientAuth'] });
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'serverAuth' }).code, 'EKU', 'noi');
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'clientAuth' }).ok, true, 'noi ca client');
has(osslVerify([], w("cl.pem", cl)).out, "unsuitable certificate purpose", "openssl");
});
// [A6] EKU pe autoritati
await test('EKU pe autoritati [A6]: o CA restransa la clientAuth cu o frunza serverAuth sub ea: EKU numind autoritatea la noi, "unsuitable certificate purpose" la adancimea 1 la OpenSSL; aceeasi CA cu o frunza clientAuth, ca client: OK la amandoi; o RADACINA restransa e aplicata la fel', () => {
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
const caClient = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Client Only CA' }, publicKey: k2.publicKey, ca: true, pathLen: 0, days: 100, eku: ['clientAuth'] });
const srv = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
const cli = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'client' }, publicKey: k3.publicKey, days: 30, eku: ['clientAuth'] });
const r = P.verifyChain({ leaf: srv, intermediates: [caClient], roots: [root], host: 'localhost', purpose: 'serverAuth' });
eq(r.code, 'EKU', 'noi'); has(r.reason, 'Client Only CA', 'numeste autoritatea');
const o = osslVerify([], w('a6-srv.pem', srv), [w('a6-ca.pem', caClient)]); has(o.out, 'unsuitable certificate purpose', 'openssl'); has(o.out, 'at 1 depth', 'openssl: la autoritate');
eq(P.verifyChain({ leaf: cli, intermediates: [caClient], roots: [root], purpose: 'clientAuth' }).ok, true, 'client sub CA de client');
eq(ossl(['verify', '-x509_strict', '-purpose', 'sslclient', '-CAfile', f('root.pem'), '-untrusted', f('a6-ca.pem'), w('a6-cli.pem', cli)]).code, 0, 'openssl client');
const k4 = P.generateKey('ml-dsa-65');
const rootC = P.issue({ issuer: null, signingKey: k4.privateKey, subject: { cn: 'Client Only Root' }, publicKey: k4.publicKey, ca: true, pathLen: 1, days: 100, eku: ['clientAuth'] });
const srv2 = P.issue({ issuer: rootC, signingKey: k4.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
const r2 = P.verifyChain({ leaf: srv2, roots: [rootC], host: 'localhost' }); eq(r2.code, 'EKU', 'radacina restransa'); has(r2.reason, 'Client Only Root', 'numeste radacina');
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('a6-rootc.pem', rootC), w('a6-srv2.pem', srv2)]).out, 'unsuitable certificate purpose', 'openssl radacina');
});
await test('o autoritate folosita ca frunza de server: LEAF_IS_CA', () => {
eq(P.verifyChain({ leaf: inter, intermediates: [], roots: [root] }).code, 'LEAF_IS_CA', 'noi');
});
// [A7] toate ancorele candidate
await test('reinnoirea radacinii [A7]: roots [expirata, valida] cu aceeasi cheie si acelasi nume: OK la noi si la OpenSSL (amandoua in CAfile); numai [expirata]: EXPIRED si "certificate has expired"; intermediar semnat incrucisat de o radacina straina pus inaintea celui bun: OK la noi pe drumul bun', () => {
const rootExp = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 1, notBefore: new Date(Date.now() - 10 * 86400000) });
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp, root], host: 'localhost' }); eq(r.ok, true, 'expirata intai ' + r.reason);
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [root, rootExp], host: 'localhost' }).ok, true, 'valida intai');
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp], host: 'localhost' }).code, 'EXPIRED', 'numai expirata');
fs.writeFileSync(f('roots2.pem'), P.pem('CERTIFICATE', rootExp) + P.pem('CERTIFICATE', root));
const o = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('roots2.pem'), '-untrusted', f('inter.pem'), f('leaf.pem')]); eq(o.code, 0, 'openssl rollover ' + o.out);
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('rootexp.pem', rootExp), '-untrusted', f('inter.pem'), f('leaf.pem')]).out, 'certificate has expired', 'openssl numai expirata');
const k2 = P.generateKey('ml-dsa-87');
const root2 = P.issue({ issuer: null, signingKey: k2.privateKey, subject: { cn: 'Alt Root 2' }, publicKey: k2.publicKey, ca: true, pathLen: 1, days: 3650 });
const cross = P.issue({ issuer: root2, signingKey: k2.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
const rc = P.verifyChain({ leaf, intermediates: [cross, inter], roots: [root], host: 'localhost' }); eq(rc.ok, true, 'cross intai ' + rc.reason); eq(rc.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'drumul bun');
const rn = P.verifyChain({ leaf, intermediates: [cross], roots: [root], host: 'localhost' }); eq(rn.code, 'UNTRUSTED', 'numai cross: niciun drum');
const oc = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), '-untrusted', w('cross.pem', cross), '-untrusted', f('inter.pem'), f('leaf.pem')]);
console.log(` (OpenSSL cu [cross, inter]: cod ${oc.code}; OpenSSL nu revine pe pasi intre intermediari, noi da)`);
});
await test('extensie critica necunoscuta (facuta de OpenSSL): CRITICAL_EXT la noi, "unhandled critical extension" la OpenSSL', () => {
const o = ossl(['req', '-x509', '-newkey', 'mldsa65', '-keyout', f('c.key'), '-out', f('c.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit',
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '1.2.3.4.5=critical,ASN1:UTF8String:x']);
eq(o.code, 0, 'openssl req ' + o.out);
const c = P.unpem(fs.readFileSync(f('c.pem'), 'utf8'))[0];
eq(P.verifyChain({ leaf: c, roots: [c], purpose: null }).code, 'CRITICAL_EXT', 'noi');
has(ossl(['verify', '-CAfile', f('c.pem'), f('c.pem')]).out, 'unhandled critical extension', 'openssl');
});
await test('forma: algoritm exterior diferit de cel interior (ALG_MISMATCH), parametri pe ML-DSA (ALG_PARAMS), DER necanonic, octeti in plus', () => {
const oid65 = Buffer.from('0609608648016503040312', 'hex'), oid44 = Buffer.from('0609608648016503040311', 'hex');
const last = leaf.lastIndexOf(oid65); const m = Buffer.from(leaf); oid44.copy(m, last);
let e = null; try { P.parseCert(m); } catch (x) { e = x; } eq(e && e.code, 'ALG_MISMATCH', 'exterior 44 / interior 65');
const withNull = Buffer.concat([Buffer.from('300d', 'hex'), oid65, Buffer.from('0500', 'hex')]);
const src = Buffer.concat([Buffer.from('300b', 'hex'), oid65]);
const j = leaf.lastIndexOf(src);
const lung = leaf.readUInt16BE(2) + 2; // continutul SEQUENCE-ului de sus (forma 30 82 LL LL)
const n = Buffer.concat([Buffer.from([0x30, 0x82]), Buffer.alloc(2), leaf.subarray(4, j), withNull, leaf.subarray(j + src.length)]);
n.writeUInt16BE(lung, 2);
e = null; try { P.parseCert(n); } catch (x) { e = x; } eq(e && e.code, 'ALG_PARAMS', 'NULL pe ML-DSA');
e = null; try { P.parseCert(Buffer.concat([leaf, Buffer.from([0])])); } catch (x) { e = x; } has(e && e.message, 'octeti dupa structura', 'coada');
const nc = Buffer.from([0x30, 0x81, 0x03, 0x02, 0x01, 0x05]); e = null; try { P.parseCert(nc); } catch (x) { e = x; } has(e && e.message, 'necanonica', 'lungime lunga pentru una scurta');
});
// [A5] SPKI
const cuSpkiRau = (der) => { const c = P.parseCert(der); const [algEl] = Dm.children(Dm.parse(c.spki)); const s = Dm.seq(algEl.raw, Dm.bits(Buffer.alloc(10, 7))); return { c, tbs: Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s : k.raw))) }; };
await test('cheie publica malformata [A5]: SPKI de 10 octeti sub id-ml-dsa pe frunza, pe intermediar si pe radacina: verdict SPKI (cu lungimea) la noi, fara exceptie; "decode error" la OpenSSL; o cheie de 1312 octeti sub id-ml-dsa-65 e tot SPKI', () => {
const resemn = (der, key, alg) => { const { tbs } = cuSpkiRau(der); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG[alg])), Dm.bits(crypto.sign(null, tbs, key))); };
const leafRau = resemn(leaf, ki.privateKey, 'ml-dsa-65'), interRau = resemn(inter, kr.privateKey, 'ml-dsa-87');
const rootRau = (() => { const { c, tbs } = cuSpkiRau(root); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-87'])), Dm.bits(c.signature)); })();
for (const [nume, o] of [['frunza', { leaf: leafRau, intermediates: [inter], roots: [root] }], ['intermediar', { leaf, intermediates: [interRau], roots: [root] }], ['radacina', { leaf, intermediates: [inter], roots: [rootRau] }]]) {
let r; try { r = P.verifyChain({ host: 'localhost', ...o }); } catch (e) { throw new Error(nume + ': EXCEPTIE ' + e.message); }
eq(r.code, 'SPKI', nume); has(r.reason, 'this one has 10', nume + ': motivul');
}
has(osslVerify([], w('a5-leaf.pem', leafRau)).out, 'decode error', 'openssl frunza');
has(osslVerify([], f('leaf.pem'), [w('a5-inter.pem', interRau)]).out, 'decode error', 'openssl intermediar');
const k44 = P.generateKey('ml-dsa-44'); const pk44 = Dm.bitString(Dm.children(Dm.parse(k44.publicKey.export({ type: 'spki', format: 'der' })))[1]).bytes;
const c = P.parseCert(leaf); const s65 = Dm.seq(Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(pk44));
const tbs = Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s65 : k.raw)));
const gresit = Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(crypto.sign(null, tbs, ki.privateKey)));
const r = V({ leaf: gresit }); eq(r.code, 'SPKI', 'cheie 44 sub OID 65'); has(r.reason, '1312', 'lungimea gasita');
});
await test('orice octeti dau verdict, nu exceptie [A5]: mutatii aleatoare ale frunzei, ale intermediarului si ale listei, trunchieri si gunoi trec prin verifyChain fara sa arunce', () => {
let verdicte = 0;
const incearca = (o) => { let r; try { r = P.verifyChain(o); } catch (e) { throw new Error('EXCEPTIE: ' + e.message); } if (typeof r.ok !== 'boolean' || !r.code) throw new Error('fara verdict'); verdicte++; };
for (let i = 0; i < 300; i++) { const b = Buffer.from(leaf); for (let j = 0; j <= i % 4; j++) b[crypto.randomInt(b.length)] ^= 1 << crypto.randomInt(8); incearca({ leaf: b, intermediates: [inter], roots: [root], host: 'localhost', crls: [crlOk] }); }
for (const len of [0, 1, 2, 3, 4, 10, 100, 700, leaf.length - 1]) incearca({ leaf: leaf.subarray(0, len), intermediates: [inter], roots: [root] });
for (let i = 0; i < 50; i++) incearca({ leaf: crypto.randomBytes(1 + crypto.randomInt(400)), roots: [root] });
for (let i = 0; i < 100; i++) { const b = Buffer.from(crlOk); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [root], crls: [b], requireCrl: true }); }
for (let i = 0; i < 100; i++) { const b = Buffer.from(inter); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [b], roots: [root] }); }
for (let i = 0; i < 50; i++) { const b = Buffer.from(root); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [b] }); }
eq(verdicte >= 609, true, 'numarul de verdicte ' + verdicte);
});
// [A10] OID
await test('OID [A10]: 2.999 se codifica 06 02 88 37 si se decodifica inapoi; 2.100.3 si alte sase OID-uri identice octet cu octet cu OpenSSL (asn1parse -genstr) si round-trip; 0x80 initial refuzat ca necanonic; un certificat OpenSSL cu extensia critica 2.999.1 e numit corect in CRITICAL_EXT', () => {
eq(Dm.oid('2.999').toString('hex'), '06028837', '2.999');
for (const o of ['2.999', '2.100.3', '2.16.840.1.101.3.4.3.18', '1.3.6.1.5.5.7.3.1', '2.5.29.19', '0.9.2342.19200300.100.1.25', '1.2.840.113549.1.1.11', '2.25.329800735698586629295641978511506172918']) {
const noi = Dm.oid(o);
eq(Dm.oidToString(Dm.parse(noi)), o, 'round-trip ' + o);
const r = ossl(['asn1parse', '-genstr', 'OID:' + o, '-noout', '-out', f('oid.der')]); eq(r.code, 0, 'openssl genstr ' + o + ' ' + r.out);
eq(fs.readFileSync(f('oid.der')).toString('hex'), noi.toString('hex'), 'octetii OpenSSL pentru ' + o);
}
let e = null; try { Dm.oidToString(Dm.parse(Buffer.from('06028001', 'hex'))); } catch (x) { e = x; } has(e && e.message, 'necanonic', '0x80 initial');
e = null; try { Dm.oid('1.40'); } catch (x) { e = x; } has(e && e.message, 'arc nevalid', '1.40');
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('c9.key'), '-out', f('c9.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit999',
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '2.999.1=critical,ASN1:UTF8String:x']);
eq(o.code, 0, 'req ' + o.out);
const c = P.unpem(fs.readFileSync(f('c9.pem'), 'utf8'))[0]; const r = P.verifyChain({ leaf: c, roots: [c], purpose: null });
eq(r.code, 'CRITICAL_EXT', 'verdict'); has(r.reason, 'extension 2.999.1', 'numele adevarat al extensiei');
});
// ------------------------------------------------------------------------------------------------ TLS real
await test('TLS 1.3 complet post-cuantic: server Node cu lantul nostru, X25519MLKEM768 + semnatura mldsa65; OpenSSL si Node il accepta', async () => {
const srv = tls.createServer({ key: fs.readFileSync(f('leaf.key')), cert: fs.readFileSync(f('leaf.pem')) + fs.readFileSync(f('inter.pem')),
minVersion: 'TLSv1.3', ecdhCurve: 'X25519MLKEM768' }, (c) => c.end('aere\n'));
await new Promise((ok) => srv.listen(0, '127.0.0.1', ok));
const port = srv.address().port;
try {
const out = await new Promise((ok) => {
const c = spawn('openssl', ['s_client', '-connect', '127.0.0.1:' + port, '-servername', 'localhost', '-verify_hostname', 'localhost',
'-CAfile', f('root.pem'), '-verify_return_error', '-groups', 'X25519MLKEM768'], { env });
let s = ''; c.stdout.on('data', (d) => { s += d; }); c.stderr.on('data', (d) => { s += d; });
c.stdin.end(); const t = setTimeout(() => c.kill(), 15000); c.on('close', () => { clearTimeout(t); ok(s); });
});
has(out, 'Verify return code: 0 (ok)', 'openssl verificarea'); has(out, 'Peer signature type: mldsa65', 'semnatura'); has(out, 'Negotiated TLS1.3 group: X25519MLKEM768', 'grupul');
const nod = await new Promise((ok) => {
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: fs.readFileSync(f('root.pem')), ecdhCurve: 'X25519MLKEM768' }, () => {
ok({ auth: s.authorized, err: s.authorizationError, proto: s.getProtocol() }); s.end();
}); s.on('error', (e) => ok({ auth: false, err: e.message }));
});
eq(nod.auth, true, 'clientul Node ' + nod.err); eq(nod.proto, 'TLSv1.3', 'protocolul');
const alt = P.generateKey('ml-dsa-65');
const altRoot = P.issue({ issuer: null, signingKey: alt.privateKey, subject: { cn: 'Alt' }, publicKey: alt.publicKey, ca: true, days: 5 });
const neg = await new Promise((ok) => {
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: P.pem('CERTIFICATE', altRoot), rejectUnauthorized: true }, () => { ok('conectat'); s.end(); });
s.on('error', (e) => ok('refuzat: ' + e.message));
});
has(neg, 'refuzat', 'clientul Node cu alta radacina');
} finally { srv.close(); }
});
// ------------------------------------------------------------------------------------------------ cheia pe disc [A1]
await test('sigilarea cheii [A1]: fisierul poarta scrypt N=2^17 r=8 p=1 si AES-256-GCM cu antetul ca AAD (citit si direct din DER); o incercare de parola costa peste 25 ms (masurat si tiparit); parola gresita, text cifrat sau antet atins: KEY_LOCKED; PKCS#8 cifrat sau in clar: KEY_FORMAT; OpenSSL nu il deschide', () => {
const pass = 'Proba-sigiliu-2026'; // AERE-SINTETIC
const sealed = P.exportPrivateKey(ki.privateKey, pass);
has(sealed, '-----BEGIN AERE PQ PKI PRIVATE KEY-----', 'eticheta');
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(sealed), false, 'fara PKCS#8');
const h = P.readSealedKeyHeader(sealed);
eq(h.N, 2 ** 17, 'N'); eq(h.r, 8, 'r'); eq(h.p, 1, 'p'); eq(h.kdf, 'scrypt', 'kdf'); eq(h.cipher, 'aes-256-gcm', 'cifrul');
eq(h.salt.length, 16, 'salt'); eq(h.iv.length, 12, 'iv'); eq(h.tag.length, 16, 'tag'); eq(h.ct.length > 4000, true, 'PKCS#8 cifrat inauntru');
const blob = P.unpem(sealed, P.KEY_LABEL)[0]; const [hdr] = Dm.children(Dm.parse(blob)); const hk = Dm.children(hdr);
eq(Number(Dm.intToBigInt(hk[3])), 131072, 'N citit direct din DER'); eq(Number(Dm.intToBigInt(hk[4])), 8, 'r din DER'); eq(Number(Dm.intToBigInt(hk[0])), 1, 'versiunea formatului');
const back = P.importPrivateKey(sealed, pass);
eq(crypto.createPublicKey(back).export({ type: 'spki', format: 'der' }).equals(ki.publicKey.export({ type: 'spki', format: 'der' })), true, 'aceeasi cheie dupa deschidere');
const t0 = process.hrtime.bigint(); let n = 0;
for (let i = 0; i < 3; i++) { try { P.importPrivateKey(sealed, 'Gresita-parola-' + i); } catch (e) { if (e.code === 'KEY_LOCKED') n++; } }
const ms = Number(process.hrtime.bigint() - t0) / 3e6; eq(n, 3, 'parola gresita = KEY_LOCKED');
console.log(` cost masurat: ${ms.toFixed(0)} ms per incercare de parola (scrypt N=${h.N}, r=${h.r}, p=${h.p}, ${os.cpus()[0]?.model?.trim() || 'cpu'})`);
if (ms < 25) throw new Error('o incercare de parola costa ' + ms.toFixed(1) + ' ms, sub 25 ms: KDF-ul e prea ieftin');
const atins = (off) => { const b = Buffer.from(blob); b[off] ^= 1; return P.pem(P.KEY_LABEL, b); };
code(() => P.importPrivateKey(atins(blob.length - 1), pass), 'KEY_LOCKED', 'text cifrat atins');
code(() => P.importPrivateKey(atins(hk[2].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'salt atins (antetul e AAD)');
code(() => P.importPrivateKey(atins(hk[7].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'iv atins');
code(() => P.importPrivateKey(sealed, ''), 'KEY_LOCKED', 'fara parola');
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: pass }), pass), 'KEY_FORMAT', 'PKCS#8 cifrat (formatul vechi)');
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem' }), pass), 'KEY_FORMAT', 'PKCS#8 in clar');
fs.writeFileSync(f('sealed.pem'), sealed);
eq(ossl(['pkey', '-in', f('sealed.pem'), '-noout']).code === 0, false, 'openssl nu poate deschide formatul sigilat');
});
await test('politica de parola [A1]: sub 12 caractere, o singura clasa sub 20, toate identice, evidente (password1234, Password123!, Qwerty123456): PASSPHRASE; 3 clase la 12 sau 20+ caractere trec; linia de comanda refuza init cu o parola slaba si nu scrie nimic', () => {
// AERE-SINTETIC: parole de proba pentru politica, niciuna folosita pentru vreo cheie
for (const p of ['scurta1A!', 'aaaaaaaaaaaa', 'abcdefghijkl', 'password1234', 'Password123!', 'Qwerty123456', '123456789012', 'AAAAAAAAAAAAAAAAAAAAAAAA', 'Letmein-2026', 'ABCDEFGHIJKL']) code(() => P.checkPassphrase(p), 'PASSPHRASE', p);
for (const p of ['Abcdefghijk1', PASS_PROBA, 'abcdefghijklmnopqrstu', 'correct horse battery staple', 'Zx9!Zx9!Zx9!']) eq(P.checkPassphrase(p), p, p);
code(() => P.exportPrivateKey(ki.privateKey, 'aaaaaaaaaaaa'), 'PASSPHRASE', 'exportul aplica politica');
const r = run(['init', '--dir', f('ca-slaba'), '--name', 'r'], { ...env, AERE_PKI_PASSPHRASE: 'aaaaaaaaaaaa' }); eq(r.code, 2, 'cli'); has(r.out, 'PASSPHRASE', 'motivul');
eq(fs.existsSync(path.join(f('ca-slaba'), 'r.key')), false, 'nimic scris'); eq(fs.existsSync(path.join(f('ca-slaba'), 'r.crt')), false, 'niciun certificat scris');
});
// ------------------------------------------------------------------------------------------------ linia de comanda
await test('linia de comanda: init, intermediate, issue, revoke, crl, verify, unseal; cheile scrise sigilate (nu PKCS#8); fara parola si cu parola gresita refuza; nimic suprascris', () => {
const D = f('ca');
eq(run(['init', '--dir', D, '--name', 'root', '--org', 'Aere Proba']).code, 0, 'init');
eq(run(['intermediate', '--dir', D, '--ca', 'root', '--name', 'issuing']).code, 0, 'intermediate');
eq(run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'svc.local', '--dns', 'svc.local', '--out', f('svc')]).code, 0, 'issue');
const rk = fs.readFileSync(path.join(D, 'root.key'), 'utf8'), sk = fs.readFileSync(f('svc.key'), 'utf8');
has(rk, 'AERE PQ PKI PRIVATE KEY', 'cheia radacinii sigilata'); has(sk, 'AERE PQ PKI PRIVATE KEY', 'cheia frunzei sigilata');
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(rk + sk), false, 'niciun PKCS#8 pe disc');
eq(P.readSealedKeyHeader(rk).N, 2 ** 17, 'N al cheii radacinii');
const ok1 = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local']); eq(ok1.code, 0, 'verify ' + ok1.out);
eq(run(['revoke', '--dir', D, '--ca', 'issuing', '--cert', f('svc.crt')]).code, 0, 'revoke');
eq(run(['crl', '--dir', D, '--ca', 'issuing', '--out', f('svc-crl.pem')]).code, 0, 'crl');
const rv = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local', '--crl', f('svc-crl.pem')]);
eq(rv.code, 1, 'verify revocat'); has(rv.out, 'REFUSED (REVOKED)', 'motivul');
const us = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us.code, 0, 'unseal ' + us.out);
has(fs.readFileSync(f('svc.p8.pem'), 'utf8'), '-----BEGIN PRIVATE KEY-----', 'PKCS#8 in clar, la cerere');
has(ossl(['pkey', '-in', f('svc.p8.pem'), '-noout', '-text']).out, 'ML-DSA-65', 'openssl citeste cheia desigilata');
const us2 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us2.code, 2, 'unseal peste un fisier existent'); has(us2.out, 'EXISTS', 'nimic suprascris la unseal');
// AERE-SINTETIC: parola gresita dinadins (proba refuzului)
const us3 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8b.pem')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(us3.code, 2, 'unseal cu parola gresita'); has(us3.out, 'KEY_LOCKED', 'motivul');
const np = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x2')], { ...env, AERE_PKI_PASSPHRASE: '' }); eq(np.code, 2, 'fara parola'); has(np.out, 'PASSPHRASE', 'motivul fara parola');
// AERE-SINTETIC: parola gresita dinadins (proba refuzului la emitere)
const wp = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x3')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(wp.code, 2, 'parola gresita'); has(wp.out, 'KEY_LOCKED', 'motivul parolei gresite');
const ow = run(['init', '--dir', D, '--name', 'root']); eq(ow.code, 2, 'suprascriere'); has(ow.out, 'EXISTS', 'nimic suprascris');
eq(/-----BEGIN PRIVATE KEY-----/.test(np.out + wp.out + ow.out + us.out + us2.out + us3.out), false, 'niciun material de cheie in mesaje');
});
fs.rmSync(T, { recursive: true, force: true });
const picate = rez.filter((r) => !r.ok);
console.log(`\n${rez.length - picate.length}/${rez.length} probe trecute`);
if (process.env.PKI_JSON) fs.writeFileSync(process.env.PKI_JSON, JSON.stringify(rez, null, 1));
process.exitCode = picate.length ? 1 : 0;