| .. | ||
| Dockerfile | ||
| pq-gateway.example.json | ||
| pq-gateway.mjs | ||
| proba-pq-gateway-control-negativ.sh | ||
| proba-pq-gateway.mjs | ||
| README.md | ||
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 isX25519MLKEM768and the server signs withmldsa65(measured withopenssl s_client, test(s)). SetAERE_PQGW_REQUIRE_PQ_AUTH=trueand the gateway refuses to start unless every served certificate has an ML-DSA key and an ML-DSA signature; the status endpoint reportscertificate.authenticationaspost-quantum,mixedorclassical. 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
UPSTREAMishttp://. That hop is plain HTTP. Keep it on a trusted network (same host, same private network, loopback) or use anhttps://upstream. When the upstream ishttps://, 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.1over ALPN. A client that offersh2andhttp/1.1getshttp/1.1. - Report the negotiated group per connection in
hybrid-preferredmode. 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
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.
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:
Connectionand every header it names,Keep-Alive,Proxy-*,TE,Trailer,Transfer-EncodingandUpgrade(except on the upgrade path, see below).Expectis handled by the gateway and not forwarded. X-Forwarded-ForandX-Real-IP(the client address, from the socket),X-Forwarded-Proto: https,X-Forwarded-Host(the client'sHost), andx-aere-pq-gateway: <mode>. Values a client sends under these names are dropped, so they cannot be forged (unless you enableAERE_PQGW_TRUST_FORWARDEDforX-Forwarded-For). Client-sentX-Client-*andX-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,alpnguarantee.hybridKeyExchangeOnEveryConnection:trueonly inhybrid-only, with the reason inguarantee.basisnegotiatedGroupPerConnection: states that the negotiated group is not reported per connection, and whycertificate: SHA-256 fingerprint, subject and expiry of the served certificate;authentication(post-quantum,mixed,classical) andchain, each served certificate with its public key and signature algorithmupstream: only the scheme and whether that hop is encrypted (not the address)runtime: Node.js and OpenSSL versionscounters:connectionsAccepted,handshakesRefusedby 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:
openssl s_client -groups X25519MLKEM768 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
Look for the line:
Negotiated TLS1.3 group: X25519MLKEM768
In hybrid-only mode, a classical-only client must be refused:
openssl s_client -groups X25519 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
This fails with alert handshake failure (alert number 40). Note that s_client still prints a
Negotiated TLS1.3 group: line on failure, with the value <NULL>, and Cipher is (NONE).
If you test with a Node.js client: on Node.js 24 with OpenSSL 3.5, tlsSocket.getEphemeralKeyInfo() returns an empty
object {} for the hybrid group (and { type: 'ECDH', name: 'X25519', ... } for X25519), so it cannot name
X25519MLKEM768. Use openssl s_client or read the key_share extension of the ServerHello from a packet capture
(group 0x11ec is X25519MLKEM768, 0x001d is X25519).
Shutdown
On SIGTERM or SIGINT the gateway stops accepting connections, lets in-flight requests finish (responses sent during
shutdown carry Connection: close), closes remaining connections and tunnels after AERE_PQGW_SHUTDOWN_GRACE_MS, and
exits on its own.
Tests
proba-pq-gateway.mjs starts everything locally on the loopback interface with ports chosen by the system: a
self-signed certificate made with the openssl command line tool in a temporary directory, an echo upstream, and the
gateway as a child process configured through its environment, exactly as you would run it. It reads the negotiated
group from the wire (the ServerHello key_share), with openssl s_client as a second, independent client. Negative
tests check the reason for each refusal, not only that it failed.
proba-pq-gateway-control-negativ.sh proves the tests can fail: it disables the gateway's safeguards one at a time
in a copy (for example, letting hybrid-only also offer X25519, removing the TLS 1.3 minimum, forwarding hop-by-hop
headers, putting the private key in the status output) and requires the matching test to fail while the whole suite
still runs.
node proba-pq-gateway.mjs
bash proba-pq-gateway-control-negativ.sh