aere-quantum/pq-gateway/README.md

206 lines
13 KiB
Markdown

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