Skip to content

Commit 548b0cd

Browse files
committed
docs: Design spec for HTTPS support via PEM files
1 parent 5acb4af commit 548b0cd

1 file changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)