206 lines
13 KiB
Markdown
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
|
|
```
|