# TLS / SSL

The app terminates TLS itself on **port 4455** in the `prod` profile, consuming
**three independent PEM files** — the source of truth. They are never converted to
a keystore, never copied into the image, and never written anywhere persistent:
the certificate chain is assembled and the key loaded **in memory** at startup
(see `config/ssl/PemSslBundleRegistrar`, ADR-0005).

## The three files

| File | Env var | Content |
|---|---|---|
| server certificate | `SSL_CERT_PATH` | the leaf certificate for this host |
| CA / intermediate chain | `SSL_CA_PATH` | the issuer chain (one or more concatenated certs; root optional) |
| private key | `SSL_KEY_PATH` | the matching key — PKCS#8 or PKCS#1, plain or passphrase-protected |

An encrypted key's passphrase comes from `SSL_KEY_PASSWORD` (environment or Docker
secret only — never a file, compose value, or log line).

Example — paths can live anywhere, in three different directories:

```bash
export SSL_ENABLED=true
export SSL_CERT_PATH=/srv/pki/demo/leaf.pem
export SSL_CA_PATH=/srv/pki/intermediates/demo-chain.pem
export SSL_KEY_PATH=/secrets/demo/leaf.key
export SSL_KEY_PASSWORD=…            # only if the key is encrypted
```

At startup, when `SSL_ENABLED=true`, the app fails fast (before the port opens) if
any file is missing/unreadable (it prints the expected uid/gid — the single most
common deployment failure), if the key does not mathematically match the leaf, or
if the chain is broken or the leaf expired. On success it logs one INFO line with
the leaf CN, SANs, issuer, serial, `notAfter`, and the number of chain certs.

## Generating a dev trio

`scripts/gen-dev-certs.sh` produces a small **root → intermediate → leaf** PKI in
the same three-file layout as production, so dev and prod use the identical code
path (not two mechanisms):

```bash
./scripts/gen-dev-certs.sh dev-certs
#   dev-certs/tls.crt  -> SSL_CERT_PATH   (leaf)
#   dev-certs/ca.crt   -> SSL_CA_PATH     (intermediate + root)
#   dev-certs/tls.key  -> SSL_KEY_PATH    (leaf key)
#   dev-certs/root.crt -> import into your client's trust store
```

## Verifying

```bash
# The chain validates against the CA material:
openssl verify -CAfile <(cat dev-certs/ca.crt dev-certs/root.crt) dev-certs/tls.crt

# Inspect the leaf (subject, SANs, validity window):
openssl x509 -in dev-certs/tls.crt -noout -text -dates

# Prove the running server serves the FULL chain (leaf + intermediate), not just the leaf:
openssl s_client -connect localhost:4455 -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'      # expect 2
```

## `SSL_CA_PATH` vs. a truststore

Serving the chain and trusting client certificates are **different jobs that
happen to use the same file**. `SSL_CA_PATH` here populates the *served chain*
(what the server sends clients). It only belongs in a **truststore** when:

- **mTLS** is on (`SSL_CLIENT_AUTH=need|want`) — then the CA is used to verify
  incoming client certificates; or
- this service acts as a **TLS client** to an internally-signed upstream — then
  the internal CA goes into that client's truststore.

By default (`SSL_CLIENT_AUTH=none`) the CA is used only to build the served chain.

## Rotation

Replace the three files on the host and restart the container — **no rebuild, no
image change, no code change**. The files are bind-mounted read-only, one per
mount (see `docker-compose.prod.yml`); the container never writes to certificate
storage. Live bundle reload without a restart is a deliberate non-goal (ADR-0005).

Permissions: the mounted files must be readable by the app user (**uid 10001**).
If a key is `root:root` mode `0600`, either `chown 10001 tls.key` on the host, add
the app user to a group that can read it, or run the container with a matching
`user:`. The startup error prints the uid/gid to fix.

## Alternative: terminate TLS at a reverse proxy

You can instead terminate TLS at nginx/Traefik and run this app on plain HTTP
behind it, forwarding `X-Forwarded-*` (the app already sets
`server.forward-headers-strategy=framework`). Trade-off: the proxy owns cert
rotation and you lose end-to-end encryption to the app on the internal hop. The
**app-level TLS path documented here stays primary** — it needs no extra moving
part and keeps the connection encrypted all the way to the JVM.
