# TLS (`bolt+s://`, HTTPS) ## Status Done: the configuration variables, the three Bolt TLS modes with sniffing on one port (`boltage/streams.py`, `handle_client` in `boltage/bolt.py`), stacked transports (raw Bolt and WebSocket over TCP or TLS), HTTPS through uvicorn, `+s` schemes in the discovery document when TLS is required, and the README documentation, including libpq's variables for TLS to PostgreSQL. The container images generate a self-signed certificate with `BOLTAGE_TLS_SELF_SIGNED=true` (`container/boltage-start.sh`), off by default, and the README explains mounting certificates and using Podman and compose secrets. Still open: reloading the certificate on `SIGHUP`, and adding the TLS checks to the tests in the repository (they were run by hand against all three modes, and `tests/smoke_test.py` passes unchanged over `bolt+ssc://` and `neo4j+ssc://`). ## Problem The Bolt port (`asyncio.start_server` in `boltage/__main__.py:46`) and the neo4j-browser HTTP server (uvicorn) only speak plain text. Drivers configured with `bolt+s://` / `neo4j+s://`, and neo4j-browser served over HTTPS (which must use `wss://`, since browsers block plain `ws://` from an HTTPS page), cannot connect. Credentials are sent in the clear, which matters because they are PostgreSQL passwords. ## Design constraints - The Bolt port serves raw Bolt, Bolt over WebSocket and plain HTTP (discovery, CORS preflight) on one port, by sniffing the first four bytes (`handle_client`, `boltage/bolt.py:349`). TLS has to fit in front of that sniffing. - Neo4j has three modes for Bolt: TLS disabled, optional (both on one port), and required. Offering the same makes migration easy. - The WebSocket transport already uses a Sans-I/O protocol over the raw stream. TLS can be added the same way, with `ssl.SSLObject` and `ssl.MemoryBIO`, which avoids needing to "un-read" the sniffed bytes. ## Approach ### Configuration | Variable | Default | Purpose | | --- | --- | --- | | `BOLTAGE_TLS_CERT` | (unset) | PEM certificate chain. | | `BOLTAGE_TLS_KEY` | (unset) | PEM private key. | | `BOLTAGE_BOLT_TLS` | `optional` when a certificate is set, else `disabled` | `disabled`, `optional` or `required`. | | `BOLTAGE_HTTPS` | `true` when a certificate is set | Serve neo4j-browser over HTTPS on `BOLTAGE_HTTP_PORT`. | Fail at start-up when only one of cert/key is set, or the files cannot be loaded. ### Bolt port 1. Build an `ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)` at start-up, minimum TLS 1.2, and pass it in `Config`. 2. In `handle_client`, read the first byte. `0x16` is a TLS handshake record: - `disabled`: close the connection. - otherwise wrap the stream in a new `TlsTransport` (an `SSLObject` over two `MemoryBIO`s, fed with the bytes already read), complete the handshake, then read the first four **decrypted** bytes and continue with the existing sniffing: `MAGIC` → raw Bolt over TLS, `GET ` → WebSocket over TLS (`wss://`), and so on. - Anything else in `required` mode: close (for an HTTP request, first answer with a plain `400` explaining that TLS is required, so the error is understandable in a browser). 3. Refactor the transports so they stack: `RawTransport` reads from the asyncio stream, `TlsTransport` reads from a lower transport, and `WebSocketTransport` reads from either. Today `WebSocketTransport` subclasses `RawTransport` and reads `self.reader` directly; change it to take a lower transport. 4. Handshake errors (client does not trust the certificate, wrong protocol) are logged at `DEBUG` and the connection closed, like other connection errors. An alternative is `loop.start_tls()` on the accepted connection, which uses asyncio's own TLS. It cannot be given the byte already read for sniffing, so it only works for `required` mode without sniffing. The `SSLObject` approach covers all three modes with one code path. ### HTTP port 1. Pass `ssl_certfile` / `ssl_keyfile` to `uvicorn.Config` when HTTPS is on. 2. The discovery document (`boltage/discovery.py`) advertises `neo4j+s://` and `bolt+s://` when Bolt TLS is `required`, and the plain schemes otherwise. neo4j-browser picks `wss://` itself when the page is served over HTTPS. 3. Optionally redirect plain HTTP to HTTPS; this needs a second port, so leave it out at first. ### Container images 1. Mount a certificate and key and set `BOLTAGE_TLS_CERT` / `BOLTAGE_TLS_KEY`. 2. For a quick start, let the entrypoint generate a self-signed certificate when `BOLTAGE_TLS=self-signed` is set (with `openssl`, which the full image has; check the slim image), stored in a volume so it survives restarts. Clients then use `bolt+ssc://` / `neo4j+ssc://`, and the browser needs a one-time exception. 3. pgAdmin in the full image has its own TLS settings; document them rather than wiring them up. ### Certificate reload Read the certificate again on `SIGHUP` by building a new context, used for new connections, so certificates from e.g. Let's Encrypt can be renewed without restarting. Optional; do it after the basics. ## Related: TLS to PostgreSQL boltage to PostgreSQL already supports TLS through libpq's environment variables (`PGSSLMODE`, `PGSSLROOTCERT`, ...), since psycopg uses libpq. That is not documented; add it to the Configuration section of the README in the same change. ## Tests - Smoke test against an instance with a self-signed certificate: `python tests/smoke_test.py bolt+ssc://localhost:7687 ...` runs the full suite unchanged, which covers raw Bolt over TLS. - `required` mode rejects `bolt://`; `disabled` mode rejects `bolt+ssc://`; `optional` accepts both on one port. - Manual check: neo4j-browser over `https://localhost:7474/` connects over `wss://`. ## README changes Remove the TLS bullet. Add the variables to the Configuration table and a short "TLS" section: modes, client URI schemes (`+s` vs `+ssc`), the container's self-signed option, and libpq's variables for the PostgreSQL side.