# 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 ```