aere-quantum/pq-gateway/README.md

13 KiB

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

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: Connection and every header it names, Keep-Alive, Proxy-*, TE, Trailer, Transfer-Encoding and Upgrade (except on the upgrade path, see below). Expect is handled by the gateway and not forwarded.
  • X-Forwarded-For and X-Real-IP (the client address, from the socket), X-Forwarded-Proto: https, X-Forwarded-Host (the client's Host), and x-aere-pq-gateway: <mode>. Values a client sends under these names are dropped, so they cannot be forged (unless you enable AERE_PQGW_TRUST_FORWARDED for X-Forwarded-For). Client-sent X-Client-* and X-SSL-* headers (the names TLS-terminating proxies use to hand over a client certificate identity) are dropped as well: this gateway does not authenticate clients, so an upstream must not see such headers as if it had.

Upgrade requests (WebSocket) are tunnelled in both directions after the upstream answers 101. If the upstream declines the upgrade, its response is passed back and the connection is closed.

If the upstream cannot be reached, the client gets 502 with a short JSON body ({"error":"bad_gateway",...}). An https:// upstream whose certificate cannot be verified is also 502: there is no option to skip verification; use AERE_PQGW_UPSTREAM_CA for a private CA. If the upstream does not connect (including its TLS handshake) or does not start responding in time, the client gets 504 ({"error":"gateway_timeout",...}). A request never hangs waiting for an upstream that does not answer. Once the upstream has started its response, the gateway applies no idle timeout to the body, so long-lived streams such as server-sent events keep working. A client that disconnects before the response is not counted as an upstream error.

Status endpoint

GET /.well-known/aere-pq-gateway is answered by the gateway itself and never forwarded. It is public, so it contains nothing secret: no private key and no file paths. It returns:

  • mode, groupsOffered, groupPreference, minTlsVersion, alpn
  • guarantee.hybridKeyExchangeOnEveryConnection: true only in hybrid-only, with the reason in guarantee.basis
  • negotiatedGroupPerConnection: states that the negotiated group is not reported per connection, and why
  • certificate: SHA-256 fingerprint, subject and expiry of the served certificate; authentication (post-quantum, mixed, classical) and chain, each served certificate with its public key and signature algorithm
  • upstream: only the scheme and whether that hop is encrypted (not the address)
  • runtime: Node.js and OpenSSL versions
  • counters: connectionsAccepted, handshakesRefused by reason (no shared group, unsupported protocol, no shared cipher, no shared signature algorithm (a client that offers no ML-DSA signature algorithm against an ML-DSA chain, which is most browsers today), handshake timeout, other), handshakesRefusedByCode (the OpenSSL error code), requestsForwarded, upgradesTunneled, responses502, responses504, statusRequests

Verifying it yourself

With OpenSSL 3.5 or later, independent of this gateway:

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