From 7d814d1bb84fcba8c860a5b2a8f90edd2faa23a1 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 29 Sep 2026 16:24:04 +0300 Subject: [PATCH] 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. --- LICENSE | 15 + README.md | 30 + pq-gateway/Dockerfile | 24 + pq-gateway/README.md | 205 +++ pq-gateway/pq-gateway.example.json | 13 + pq-gateway/pq-gateway.mjs | 584 +++++++ .../proba-pq-gateway-control-negativ.sh | 144 ++ pq-gateway/proba-pq-gateway.mjs | 965 ++++++++++++ pq-kms/.gitignore | 2 + pq-kms/README.md | 284 ++++ pq-kms/hsm-radacina.mjs | 142 ++ pq-kms/kms.mjs | 912 +++++++++++ pq-kms/server.mjs | 333 ++++ pq-kms/test/control-negativ-hsm-incredere.mjs | 58 + pq-kms/test/control-negativ.mjs | 423 ++++++ pq-kms/test/proba-hsm-incredere.mjs | 41 + pq-kms/test/proba-hsm.mjs | 88 ++ pq-kms/test/proba.mjs | 1339 +++++++++++++++++ pq-pki/README.md | 67 + pq-pki/cli.mjs | 134 ++ pq-pki/der.mjs | 165 ++ pq-pki/pki.mjs | 520 +++++++ pq-pki/test/control-negativ.mjs | 99 ++ pq-pki/test/proba.mjs | 417 +++++ 24 files changed, 7004 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pq-gateway/Dockerfile create mode 100644 pq-gateway/README.md create mode 100644 pq-gateway/pq-gateway.example.json create mode 100644 pq-gateway/pq-gateway.mjs create mode 100644 pq-gateway/proba-pq-gateway-control-negativ.sh create mode 100644 pq-gateway/proba-pq-gateway.mjs create mode 100644 pq-kms/.gitignore create mode 100644 pq-kms/README.md create mode 100644 pq-kms/hsm-radacina.mjs create mode 100644 pq-kms/kms.mjs create mode 100644 pq-kms/server.mjs create mode 100644 pq-kms/test/control-negativ-hsm-incredere.mjs create mode 100644 pq-kms/test/control-negativ.mjs create mode 100644 pq-kms/test/proba-hsm-incredere.mjs create mode 100644 pq-kms/test/proba-hsm.mjs create mode 100644 pq-kms/test/proba.mjs create mode 100644 pq-pki/README.md create mode 100644 pq-pki/cli.mjs create mode 100644 pq-pki/der.mjs create mode 100644 pq-pki/pki.mjs create mode 100644 pq-pki/test/control-negativ.mjs create mode 100644 pq-pki/test/proba.mjs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8ffba4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4878286 --- /dev/null +++ b/README.md @@ -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). diff --git a/pq-gateway/Dockerfile b/pq-gateway/Dockerfile new file mode 100644 index 0000000..6c62329 --- /dev/null +++ b/pq-gateway/Dockerfile @@ -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"] diff --git a/pq-gateway/README.md b/pq-gateway/README.md new file mode 100644 index 0000000..47e8eaa --- /dev/null +++ b/pq-gateway/README.md @@ -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: `. 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 `, 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 +``` diff --git a/pq-gateway/pq-gateway.example.json b/pq-gateway/pq-gateway.example.json new file mode 100644 index 0000000..35739cf --- /dev/null +++ b/pq-gateway/pq-gateway.example.json @@ -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 +} diff --git a/pq-gateway/pq-gateway.mjs b/pq-gateway/pq-gateway.mjs new file mode 100644 index 0000000..cd90d8f --- /dev/null +++ b/pq-gateway/pq-gateway.mjs @@ -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(); diff --git a/pq-gateway/proba-pq-gateway-control-negativ.sh b/pq-gateway/proba-pq-gateway-control-negativ.sh new file mode 100644 index 0000000..0ee9189 --- /dev/null +++ b/pq-gateway/proba-pq-gateway-control-negativ.sh @@ -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 diff --git a/pq-gateway/proba-pq-gateway.mjs b/pq-gateway/proba-pq-gateway.mjs new file mode 100644 index 0000000..28b48f5 --- /dev/null +++ b/pq-gateway/proba-pq-gateway.mjs @@ -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 '', 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 === '' || /$/.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(); diff --git a/pq-kms/.gitignore b/pq-kms/.gitignore new file mode 100644 index 0000000..7c4ad69 --- /dev/null +++ b/pq-kms/.gitignore @@ -0,0 +1,2 @@ +# dosarul implicit de date (chei sigilate, jurnal de audit); nu intra niciodata in depozit +data/ diff --git a/pq-kms/README.md b/pq-kms/README.md new file mode 100644 index 0000000..c88b1c4 --- /dev/null +++ b/pq-kms/README.md @@ -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= # required; clients send "Authorization: Bearer " +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 `. + +| 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:`, 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:` + +``` +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:` + +``` +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/.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. diff --git a/pq-kms/hsm-radacina.mjs b/pq-kms/hsm-radacina.mjs new file mode 100644 index 0000000..c045121 --- /dev/null +++ b/pq-kms/hsm-radacina.mjs @@ -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 --token --id --iesire [--nou] +// radacina: AERE_KMS_ROOT_KEY (o radacina existenta) sau --nou (una noua, aleatoare); PIN-ul: AERE_HSM_PIN +// node hsm-radacina.mjs verifica 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 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 --token