# ADR 0005 — TLS from the cert/CA/key trio via an in-memory SSL bundle

- Status: Accepted
- Date: 2026-08-19

## Context

Production provides three independent PEM files — leaf certificate, CA/intermediate
chain, and private key — at arbitrary paths (§6.1). Spring Boot's PEM SSL bundle
takes the certificate as a *single* resource, but our leaf and chain live in two
different files, and the served chain must be leaf-first followed by the
intermediates. We must not require a keystore conversion or write any converted
material to disk, and it must work on a read-only container filesystem.

## Decision

We implemented the spec's **preferred** option: a `SslBundleRegistrar`
(`config/ssl/PemSslBundleRegistrar`) that, when `SSL_ENABLED=true`:

1. Parses X.509 certificates from both `SSL_CERT_PATH` and `SSL_CA_PATH`.
2. Assembles the chain **in memory**, leaf-first, ordering intermediates by
   issuer→subject links (so a wrongly-ordered CA file is fixed) and stripping any
   self-signed root.
3. Loads the private key with BouncyCastle — PKCS#8 (`PRIVATE KEY`), encrypted
   PKCS#8 (`ENCRYPTED PRIVATE KEY`), and PKCS#1 (`RSA PRIVATE KEY`), plain or
   passphrase-protected via `SSL_KEY_PASSWORD`.
4. Builds an **in-memory PKCS12 keystore** (random per-run entry password that
   never leaves the JVM) and registers it as the `server` SSL bundle, which
   `server.ssl.bundle=server` consumes. No temp files; read-only FS friendly.

Because the key entry carries the full assembled chain, Tomcat serves **leaf +
intermediates** during the handshake — solving the two-file problem. An
integration test boots real HTTPS and asserts exactly `[leaf, intermediate]` is
presented to a client trusting only the dev root.

### Startup validation (fail fast, before the port opens)

The loader throws `SslConfigurationException` (an `IllegalStateException`, so it
aborts context refresh) with a specific message for each failure: file
missing/not-regular/unreadable (printing the process uid/gid), key not matching
the leaf (sign/verify round-trip), broken chain (issuer/subject + signature), and
an expired/not-yet-valid leaf. On success, one INFO line records leaf CN, SANs,
issuer, serial, `notAfter`, and chain length. Key material and the passphrase are
never logged.

### Observability

An `ssl` Actuator health indicator reports days-to-expiry per chain certificate,
going `OUT_OF_SERVICE` under 30 days and `DOWN` under 14. A Micrometer gauge
`ssl.certificate.expiry.days` (minimum across the chain) makes expiry alertable.
Both exist only when SSL is enabled.

## Consequences

- Rotation is replace-files-and-restart; no rebuild or code change. **Live bundle
  reload without a restart is a deliberate non-goal** for now — it adds watch/
  reload machinery for a rare operation on a single-instance service; documented
  in `docs/ssl.md`.
- BouncyCastle (`bcpkix`) is added for PEM/PKCS parsing (already used `bcprov` for
  Argon2).
- The dev PKI is intentionally **3-level** (root → intermediate → leaf) so dev and
  prod exercise the same "leaf + intermediate" chain assembly.
