|
| 1 | +# HTTPS support — design |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Let consumers serve their OpenAPI over HTTPS by pointing the builder at PEM |
| 6 | +files — the exact files certbot / Let's Encrypt write to disk. No keystore |
| 7 | +construction, no PKCS12 conversion, no BouncyCastle. |
| 8 | + |
| 9 | +## Non-goals (v1) |
| 10 | + |
| 11 | +- Encrypted / password-protected private keys (PKCS#8 `EncryptedPrivateKeyInfo`). |
| 12 | + Certbot writes unencrypted PKCS#8 by default; that is the supported shape. |
| 13 | +- PKCS12 / JKS keystore inputs. Users with a keystore can convert to PEM with |
| 14 | + `openssl pkcs12 -in keystore.p12 -nokeys -out fullchain.pem` / |
| 15 | + `-nocerts -nodes -out privkey.pem`, or wait for a future |
| 16 | + `.https(SSLContext)` escape hatch. |
| 17 | +- HTTP + HTTPS coexistence on one `OpenApiServer`. HTTPS replaces HTTP when |
| 18 | + configured; run two instances if you need both. |
| 19 | +- Hot reload on certificate rotation. Renewal → restart the process. |
| 20 | +- TLS protocol / cipher overrides. JDK defaults (TLS 1.2 + 1.3). |
| 21 | +- Classpath / `InputStream` inputs. `Path` only, consistent with |
| 22 | + `Spec.fromPath(Path)`. |
| 23 | + |
| 24 | +Each of these can be added later without breaking the v1 API. |
| 25 | + |
| 26 | +## Public API |
| 27 | + |
| 28 | +One new method on `OpenApiServer.Builder`: |
| 29 | + |
| 30 | +```java |
| 31 | +public Builder https(Path certificateChainPem, Path privateKeyPem) |
| 32 | +``` |
| 33 | + |
| 34 | +- `certificateChainPem` — server certificate followed by any intermediates, |
| 35 | + concatenated PEM. Matches certbot's `fullchain.pem`. |
| 36 | +- `privateKeyPem` — unencrypted PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`). |
| 37 | + Matches certbot's `privkey.pem`. Both RSA and EC keys are accepted. |
| 38 | + |
| 39 | +Both arguments are required when the method is called; either being `null` |
| 40 | +fails fast with `NullPointerException` at builder time. |
| 41 | + |
| 42 | +### Port behaviour |
| 43 | + |
| 44 | +- Default port flips to `8443` when `.https(...)` is set; stays `8080` |
| 45 | + otherwise. |
| 46 | +- `Builder.port(int)` overrides the default as today, including `0` for an |
| 47 | + ephemeral port. |
| 48 | +- `OpenApiServer.listenPort()` returns whatever was actually bound. |
| 49 | + |
| 50 | +### Failure model |
| 51 | + |
| 52 | +All HTTPS setup failures surface as `IllegalStateException` from `build()` |
| 53 | +with a message naming the file and the specific problem: |
| 54 | + |
| 55 | +| Cause | Message shape | |
| 56 | +| -------------------------------------------- | --------------------------------------------------------------- | |
| 57 | +| File missing / unreadable | `Cannot read TLS certificate chain: <path>` | |
| 58 | +| Certificate PEM malformed | `Failed to parse TLS certificate chain from <path>` | |
| 59 | +| Private key PEM malformed | `Failed to parse TLS private key from <path>` | |
| 60 | +| Key algorithm neither RSA nor EC | `Unsupported TLS private key algorithm in <path>` | |
| 61 | +| Cert/key mismatch (KeyManagerFactory rejects)| `TLS certificate and private key do not match` | |
| 62 | + |
| 63 | +The original cause is chained. |
| 64 | + |
| 65 | +## Internals |
| 66 | + |
| 67 | +### New class: `com.retailsvc.http.internal.PemSslContext` |
| 68 | + |
| 69 | +Package-private, single static entry point: |
| 70 | + |
| 71 | +```java |
| 72 | +final class PemSslContext { |
| 73 | + static SSLContext load(Path certChainPem, Path privateKeyPem); |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +Steps: |
| 78 | + |
| 79 | +1. Read all bytes of `certChainPem`. Feed to |
| 80 | + `CertificateFactory.getInstance("X.509").generateCertificates(in)` → |
| 81 | + `Collection<? extends Certificate>` → `Certificate[]`. The JDK handles |
| 82 | + concatenated PEM natively, so no manual splitting is required. |
| 83 | +2. Read `privateKeyPem` as UTF-8. Strip `-----BEGIN PRIVATE KEY-----`, |
| 84 | + `-----END PRIVATE KEY-----`, and all whitespace. Base64-decode the |
| 85 | + remainder into a `byte[]` and wrap in `PKCS8EncodedKeySpec`. |
| 86 | +3. Recover the `PrivateKey`: try `KeyFactory.getInstance("RSA") |
| 87 | + .generatePrivate(spec)`; on `InvalidKeySpecException` try `"EC"`. If both |
| 88 | + fail, throw `IllegalStateException` with the "unsupported algorithm" |
| 89 | + message. |
| 90 | +4. Build an in-memory keystore: `KeyStore ks = KeyStore.getInstance("PKCS12"); |
| 91 | + ks.load(null, null); ks.setKeyEntry("server", key, new char[0], chain);`. |
| 92 | +5. Initialise key managers: `KeyManagerFactory kmf = |
| 93 | + KeyManagerFactory.getInstance("SunX509"); kmf.init(ks, new char[0]);`. A |
| 94 | + mismatch between key and cert surfaces here and is translated to the |
| 95 | + "do not match" message. |
| 96 | +6. `SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(kmf.getKeyManagers(), |
| 97 | + null, null); return ctx;` |
| 98 | + |
| 99 | +Each step catches the narrowest checked / runtime exception it can produce |
| 100 | +and rethrows `IllegalStateException` with the message table above. No |
| 101 | +`Throwable` catch-alls. |
| 102 | + |
| 103 | +### Wiring in `OpenApiServer` |
| 104 | + |
| 105 | +Two new fields on `Builder`: |
| 106 | + |
| 107 | +```java |
| 108 | +private Path httpsCertChain; |
| 109 | +private Path httpsPrivateKey; |
| 110 | +``` |
| 111 | + |
| 112 | +Set by `.https(...)`. Default port resolution in `build()`: |
| 113 | + |
| 114 | +```java |
| 115 | +int resolvedPort = port != null ? port : (httpsCertChain != null ? 8443 : 8080); |
| 116 | +``` |
| 117 | + |
| 118 | +(`port` becomes `Integer` so we can distinguish "user set it" from "use |
| 119 | +default". This is an internal refactor; the public `port(int)` signature is |
| 120 | +unchanged.) |
| 121 | + |
| 122 | +Server creation: |
| 123 | + |
| 124 | +```java |
| 125 | +HttpServer server; |
| 126 | +if (httpsCertChain != null) { |
| 127 | + SSLContext sslContext = PemSslContext.load(httpsCertChain, httpsPrivateKey); |
| 128 | + HttpsServer https = HttpsServer.create(new InetSocketAddress(host, resolvedPort), 0); |
| 129 | + https.setHttpsConfigurator(new HttpsConfigurator(sslContext)); |
| 130 | + server = https; |
| 131 | +} else { |
| 132 | + server = HttpServer.create(new InetSocketAddress(host, resolvedPort), 0); |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +`HttpsServer extends HttpServer`, so every existing call site — context |
| 137 | +registration, executor wiring, filters, extra routes, shutdown — is |
| 138 | +untouched. |
| 139 | + |
| 140 | +## Tests |
| 141 | + |
| 142 | +### Unit: `PemSslContextTest` |
| 143 | + |
| 144 | +Fixtures under `src/test/resources/tls/`: |
| 145 | + |
| 146 | +- `rsa-cert.pem`, `rsa-key.pem` — self-signed RSA cert + PKCS#8 key |
| 147 | +- `ec-cert.pem`, `ec-key.pem` — self-signed EC (P-256) cert + PKCS#8 key |
| 148 | +- `mismatched-key.pem` — RSA key that does not match `rsa-cert.pem` |
| 149 | +- `garbage.pem` — random bytes inside PEM headers |
| 150 | + |
| 151 | +Generated once via `openssl req -newkey rsa:2048 -x509 -days 3650 -nodes ...` |
| 152 | +(and `-newkey ec:<(openssl ecparam -name prime256v1)` for EC), committed to |
| 153 | +the repo. These are test fixtures, not secrets. |
| 154 | + |
| 155 | +Cases: |
| 156 | + |
| 157 | +- RSA happy path → non-null `SSLContext`, key managers initialised. |
| 158 | +- EC happy path → non-null `SSLContext`. |
| 159 | +- Missing cert file → `IllegalStateException` with "Cannot read" message. |
| 160 | +- Missing key file → ditto. |
| 161 | +- Garbage cert PEM → "Failed to parse TLS certificate chain". |
| 162 | +- Garbage key PEM → "Failed to parse TLS private key". |
| 163 | +- Mismatched cert + key → "do not match". |
| 164 | + |
| 165 | +### Integration: `OpenApiServerHttpsIT` |
| 166 | + |
| 167 | +Boots an `OpenApiServer` on port `0` with `.https(rsaCert, rsaKey)` and a |
| 168 | +single handler. Builds an `HttpClient` whose `SSLContext` trusts only the |
| 169 | +test certificate, sends `GET /…`, asserts `200` + expected body. Mirrors the |
| 170 | +shape of `OpenApiServerIT`. Repeated for the EC fixture so we exercise both |
| 171 | +algorithms end-to-end. |
| 172 | + |
| 173 | +### Negative integration |
| 174 | + |
| 175 | +Build-time failures (`IllegalStateException` thrown from `.build()`) for: |
| 176 | + |
| 177 | +- non-existent cert path |
| 178 | +- mismatched cert/key |
| 179 | + |
| 180 | +The unit tests already cover most error paths; these two confirm the |
| 181 | +exception propagates through the builder. |
| 182 | + |
| 183 | +## Documentation |
| 184 | + |
| 185 | +New `### HTTPS` subsection in `README.md` under `## Server configuration`, |
| 186 | +placed immediately before `### Graceful shutdown`. Content: |
| 187 | + |
| 188 | +1. The `.https(certChain, privateKey)` call with a code sample. |
| 189 | +2. A short paragraph noting certbot's `fullchain.pem` + `privkey.pem` map |
| 190 | + directly onto the two arguments — no conversion needed. |
| 191 | +3. The port-default note (8443 when HTTPS, 8080 otherwise; `port(int)` |
| 192 | + overrides). |
| 193 | +4. A `openssl req -newkey rsa:2048 -nodes -keyout privkey.pem -x509 -days |
| 194 | + 365 -out fullchain.pem -subj "/CN=localhost"` one-liner for local |
| 195 | + self-signed dev certs, with the caveat that browsers/clients need to |
| 196 | + trust it explicitly. |
| 197 | +5. The non-goals list as a short "Not in this release" bullet list so users |
| 198 | + aren't surprised: no encrypted keys, no keystore inputs, no hot reload, |
| 199 | + no TLS-config knobs, no HTTP+HTTPS coexistence. |
| 200 | + |
| 201 | +Table of contents updated; subsection cross-link added next to `bindAddress` |
| 202 | +where appropriate. |
| 203 | + |
| 204 | +## Out of scope follow-ups (post-v1) |
| 205 | + |
| 206 | +These are flagged here so we don't paint ourselves into a corner. The v1 |
| 207 | +API leaves room for each: |
| 208 | + |
| 209 | +- `.https(SSLContext)` overload for mTLS, custom trust managers, or keys |
| 210 | + loaded from Vault / KMS. |
| 211 | +- Encrypted PKCS#8 support via a `char[] password` overload of `.https(...)`. |
| 212 | +- Cert hot reload: a `WatchService` on the PEM directory swapping the |
| 213 | + `SSLContext` inside a wrapping `HttpsConfigurator.configure(HttpsParameters)`. |
| 214 | +- Dual binding (HTTP + HTTPS on different ports in one server). |
0 commit comments