From 548b0cd177395dfbdb53373d11838c77549ad5d2 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:20:56 +0200 Subject: [PATCH 01/15] docs: Design spec for HTTPS support via PEM files --- .../specs/2026-05-21-https-support-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-https-support-design.md diff --git a/docs/superpowers/specs/2026-05-21-https-support-design.md b/docs/superpowers/specs/2026-05-21-https-support-design.md new file mode 100644 index 0000000..7d7b0ce --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-https-support-design.md @@ -0,0 +1,214 @@ +# HTTPS support — design + +## Goal + +Let consumers serve their OpenAPI over HTTPS by pointing the builder at PEM +files — the exact files certbot / Let's Encrypt write to disk. No keystore +construction, no PKCS12 conversion, no BouncyCastle. + +## Non-goals (v1) + +- Encrypted / password-protected private keys (PKCS#8 `EncryptedPrivateKeyInfo`). + Certbot writes unencrypted PKCS#8 by default; that is the supported shape. +- PKCS12 / JKS keystore inputs. Users with a keystore can convert to PEM with + `openssl pkcs12 -in keystore.p12 -nokeys -out fullchain.pem` / + `-nocerts -nodes -out privkey.pem`, or wait for a future + `.https(SSLContext)` escape hatch. +- HTTP + HTTPS coexistence on one `OpenApiServer`. HTTPS replaces HTTP when + configured; run two instances if you need both. +- Hot reload on certificate rotation. Renewal → restart the process. +- TLS protocol / cipher overrides. JDK defaults (TLS 1.2 + 1.3). +- Classpath / `InputStream` inputs. `Path` only, consistent with + `Spec.fromPath(Path)`. + +Each of these can be added later without breaking the v1 API. + +## Public API + +One new method on `OpenApiServer.Builder`: + +```java +public Builder https(Path certificateChainPem, Path privateKeyPem) +``` + +- `certificateChainPem` — server certificate followed by any intermediates, + concatenated PEM. Matches certbot's `fullchain.pem`. +- `privateKeyPem` — unencrypted PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`). + Matches certbot's `privkey.pem`. Both RSA and EC keys are accepted. + +Both arguments are required when the method is called; either being `null` +fails fast with `NullPointerException` at builder time. + +### Port behaviour + +- Default port flips to `8443` when `.https(...)` is set; stays `8080` + otherwise. +- `Builder.port(int)` overrides the default as today, including `0` for an + ephemeral port. +- `OpenApiServer.listenPort()` returns whatever was actually bound. + +### Failure model + +All HTTPS setup failures surface as `IllegalStateException` from `build()` +with a message naming the file and the specific problem: + +| Cause | Message shape | +| -------------------------------------------- | --------------------------------------------------------------- | +| File missing / unreadable | `Cannot read TLS certificate chain: ` | +| Certificate PEM malformed | `Failed to parse TLS certificate chain from ` | +| Private key PEM malformed | `Failed to parse TLS private key from ` | +| Key algorithm neither RSA nor EC | `Unsupported TLS private key algorithm in ` | +| Cert/key mismatch (KeyManagerFactory rejects)| `TLS certificate and private key do not match` | + +The original cause is chained. + +## Internals + +### New class: `com.retailsvc.http.internal.PemSslContext` + +Package-private, single static entry point: + +```java +final class PemSslContext { + static SSLContext load(Path certChainPem, Path privateKeyPem); +} +``` + +Steps: + +1. Read all bytes of `certChainPem`. Feed to + `CertificateFactory.getInstance("X.509").generateCertificates(in)` → + `Collection` → `Certificate[]`. The JDK handles + concatenated PEM natively, so no manual splitting is required. +2. Read `privateKeyPem` as UTF-8. Strip `-----BEGIN PRIVATE KEY-----`, + `-----END PRIVATE KEY-----`, and all whitespace. Base64-decode the + remainder into a `byte[]` and wrap in `PKCS8EncodedKeySpec`. +3. Recover the `PrivateKey`: try `KeyFactory.getInstance("RSA") + .generatePrivate(spec)`; on `InvalidKeySpecException` try `"EC"`. If both + fail, throw `IllegalStateException` with the "unsupported algorithm" + message. +4. Build an in-memory keystore: `KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); ks.setKeyEntry("server", key, new char[0], chain);`. +5. Initialise key managers: `KeyManagerFactory kmf = + KeyManagerFactory.getInstance("SunX509"); kmf.init(ks, new char[0]);`. A + mismatch between key and cert surfaces here and is translated to the + "do not match" message. +6. `SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(kmf.getKeyManagers(), + null, null); return ctx;` + +Each step catches the narrowest checked / runtime exception it can produce +and rethrows `IllegalStateException` with the message table above. No +`Throwable` catch-alls. + +### Wiring in `OpenApiServer` + +Two new fields on `Builder`: + +```java +private Path httpsCertChain; +private Path httpsPrivateKey; +``` + +Set by `.https(...)`. Default port resolution in `build()`: + +```java +int resolvedPort = port != null ? port : (httpsCertChain != null ? 8443 : 8080); +``` + +(`port` becomes `Integer` so we can distinguish "user set it" from "use +default". This is an internal refactor; the public `port(int)` signature is +unchanged.) + +Server creation: + +```java +HttpServer server; +if (httpsCertChain != null) { + SSLContext sslContext = PemSslContext.load(httpsCertChain, httpsPrivateKey); + HttpsServer https = HttpsServer.create(new InetSocketAddress(host, resolvedPort), 0); + https.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + server = https; +} else { + server = HttpServer.create(new InetSocketAddress(host, resolvedPort), 0); +} +``` + +`HttpsServer extends HttpServer`, so every existing call site — context +registration, executor wiring, filters, extra routes, shutdown — is +untouched. + +## Tests + +### Unit: `PemSslContextTest` + +Fixtures under `src/test/resources/tls/`: + +- `rsa-cert.pem`, `rsa-key.pem` — self-signed RSA cert + PKCS#8 key +- `ec-cert.pem`, `ec-key.pem` — self-signed EC (P-256) cert + PKCS#8 key +- `mismatched-key.pem` — RSA key that does not match `rsa-cert.pem` +- `garbage.pem` — random bytes inside PEM headers + +Generated once via `openssl req -newkey rsa:2048 -x509 -days 3650 -nodes ...` +(and `-newkey ec:<(openssl ecparam -name prime256v1)` for EC), committed to +the repo. These are test fixtures, not secrets. + +Cases: + +- RSA happy path → non-null `SSLContext`, key managers initialised. +- EC happy path → non-null `SSLContext`. +- Missing cert file → `IllegalStateException` with "Cannot read" message. +- Missing key file → ditto. +- Garbage cert PEM → "Failed to parse TLS certificate chain". +- Garbage key PEM → "Failed to parse TLS private key". +- Mismatched cert + key → "do not match". + +### Integration: `OpenApiServerHttpsIT` + +Boots an `OpenApiServer` on port `0` with `.https(rsaCert, rsaKey)` and a +single handler. Builds an `HttpClient` whose `SSLContext` trusts only the +test certificate, sends `GET /…`, asserts `200` + expected body. Mirrors the +shape of `OpenApiServerIT`. Repeated for the EC fixture so we exercise both +algorithms end-to-end. + +### Negative integration + +Build-time failures (`IllegalStateException` thrown from `.build()`) for: + +- non-existent cert path +- mismatched cert/key + +The unit tests already cover most error paths; these two confirm the +exception propagates through the builder. + +## Documentation + +New `### HTTPS` subsection in `README.md` under `## Server configuration`, +placed immediately before `### Graceful shutdown`. Content: + +1. The `.https(certChain, privateKey)` call with a code sample. +2. A short paragraph noting certbot's `fullchain.pem` + `privkey.pem` map + directly onto the two arguments — no conversion needed. +3. The port-default note (8443 when HTTPS, 8080 otherwise; `port(int)` + overrides). +4. A `openssl req -newkey rsa:2048 -nodes -keyout privkey.pem -x509 -days + 365 -out fullchain.pem -subj "/CN=localhost"` one-liner for local + self-signed dev certs, with the caveat that browsers/clients need to + trust it explicitly. +5. The non-goals list as a short "Not in this release" bullet list so users + aren't surprised: no encrypted keys, no keystore inputs, no hot reload, + no TLS-config knobs, no HTTP+HTTPS coexistence. + +Table of contents updated; subsection cross-link added next to `bindAddress` +where appropriate. + +## Out of scope follow-ups (post-v1) + +These are flagged here so we don't paint ourselves into a corner. The v1 +API leaves room for each: + +- `.https(SSLContext)` overload for mTLS, custom trust managers, or keys + loaded from Vault / KMS. +- Encrypted PKCS#8 support via a `char[] password` overload of `.https(...)`. +- Cert hot reload: a `WatchService` on the PEM directory swapping the + `SSLContext` inside a wrapping `HttpsConfigurator.configure(HttpsParameters)`. +- Dual binding (HTTP + HTTPS on different ports in one server). From 200b884523c1900a5f944bc25e2f4954183d2233 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:29:13 +0200 Subject: [PATCH 02/15] docs: Implementation plan for HTTPS support --- .../plans/2026-05-21-https-support.md | 907 ++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-https-support.md diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md new file mode 100644 index 0000000..38bc04c --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -0,0 +1,907 @@ +# HTTPS support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Builder.https(Path certChain, Path privateKey)` so consumers can serve HTTPS by pointing the library at PEM files written by certbot / Let's Encrypt — no PKCS12 keystore conversion, no BouncyCastle. + +**Architecture:** A package-private `PemSslContext.load(Path, Path)` turns the two PEM files into a `javax.net.ssl.SSLContext` using only JDK APIs (`CertificateFactory`, `PKCS8EncodedKeySpec`, `KeyFactory`, in-memory `PKCS12 KeyStore`, `KeyManagerFactory`, `SSLContext`). `OpenApiServer.build()` switches from `HttpServer.create(...)` to `HttpsServer.create(...) + setHttpsConfigurator(new HttpsConfigurator(sslContext))` when the HTTPS fields are populated. Default port flips to 8443 when HTTPS is enabled. + +**Tech Stack:** Java 25, JDK `com.sun.net.httpserver.HttpsServer`, `javax.net.ssl`, `java.security`. JUnit 5 + AssertJ + Java 11 `HttpClient` for the integration test. `openssl` (host tool, one-time) to generate test fixtures. + +**Spec:** `docs/superpowers/specs/2026-05-21-https-support-design.md` + +--- + +## File structure + +**Production code** + +- Create: `src/main/java/com/retailsvc/http/internal/PemSslContext.java` — package-private utility. Single static method `load(Path certChainPem, Path privateKeyPem) -> SSLContext`. Owns all PEM parsing and `SSLContext` assembly. +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` — add `httpsCertChain` / `httpsPrivateKey` fields on `Builder`, change `port` field from `int` to `Integer` so we can detect "user set" vs "default", add `Builder.https(Path, Path)` public method, branch on HTTPS in `build()` to construct `HttpsServer` instead of `HttpServer`. Constructor gets two new parameters (cert chain, key). + +**Tests** + +- Create: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` — unit tests for happy paths (RSA, EC) and error paths (missing file, garbage PEM, key/cert mismatch). +- Create: `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java` — boots the server with `.https(...)` on port `0`, hits it with a `HttpClient` configured to trust the test cert, asserts 200 + body. Repeated for RSA and EC fixtures. + +**Test fixtures** + +- Create: `src/test/resources/tls/rsa-cert.pem`, `src/test/resources/tls/rsa-key.pem` — self-signed RSA cert + matching PKCS#8 key for `CN=localhost`. +- Create: `src/test/resources/tls/ec-cert.pem`, `src/test/resources/tls/ec-key.pem` — self-signed P-256 EC cert + matching PKCS#8 key for `CN=localhost`. +- Create: `src/test/resources/tls/mismatched-key.pem` — a second RSA private key that does NOT match `rsa-cert.pem`. +- Create: `src/test/resources/tls/garbage.pem` — PEM headers wrapping random bytes. + +**Docs** + +- Modify: `README.md` — new `### HTTPS` subsection under `## Server configuration`, placed immediately before `### Graceful shutdown`. Table of contents updated. + +--- + +## Task 1: Generate and commit test fixtures + +**Files:** + +- Create: `src/test/resources/tls/rsa-cert.pem` +- Create: `src/test/resources/tls/rsa-key.pem` +- Create: `src/test/resources/tls/ec-cert.pem` +- Create: `src/test/resources/tls/ec-key.pem` +- Create: `src/test/resources/tls/mismatched-key.pem` +- Create: `src/test/resources/tls/garbage.pem` + +These are deterministic test fixtures, not secrets. They're committed once and reused. + +- [ ] **Step 1: Ensure the fixture directory exists** + +Run: + +```bash +mkdir -p src/test/resources/tls +``` + +- [ ] **Step 2: Generate the RSA self-signed cert + PKCS#8 key** + +Run from the repo root: + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout src/test/resources/tls/rsa-key.pem \ + -out src/test/resources/tls/rsa-cert.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +`-nodes` makes the key unencrypted. `openssl req` writes PKCS#8 (`-----BEGIN PRIVATE KEY-----`) by default on modern OpenSSL (1.1.1+). `subjectAltName` is required for Java's `HttpClient` to accept the cert without warning. + +- [ ] **Step 3: Verify the RSA key is PKCS#8 unencrypted** + +Run: + +```bash +head -1 src/test/resources/tls/rsa-key.pem +``` + +Expected: `-----BEGIN PRIVATE KEY-----` (not `-----BEGIN RSA PRIVATE KEY-----` or `-----BEGIN ENCRYPTED PRIVATE KEY-----`). If the header is wrong, the OpenSSL on this machine writes legacy PKCS#1; convert with: + +```bash +openssl pkcs8 -topk8 -nocrypt -in src/test/resources/tls/rsa-key.pem \ + -out src/test/resources/tls/rsa-key.pem.tmp \ + && mv src/test/resources/tls/rsa-key.pem.tmp src/test/resources/tls/rsa-key.pem +``` + +- [ ] **Step 4: Generate the EC (P-256) self-signed cert + PKCS#8 key** + +Run: + +```bash +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \ + -keyout src/test/resources/tls/ec-key.pem \ + -out src/test/resources/tls/ec-cert.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +- [ ] **Step 5: Verify the EC key is PKCS#8 unencrypted** + +Run: + +```bash +head -1 src/test/resources/tls/ec-key.pem +``` + +Expected: `-----BEGIN PRIVATE KEY-----`. If it says `-----BEGIN EC PRIVATE KEY-----`, convert: + +```bash +openssl pkcs8 -topk8 -nocrypt -in src/test/resources/tls/ec-key.pem \ + -out src/test/resources/tls/ec-key.pem.tmp \ + && mv src/test/resources/tls/ec-key.pem.tmp src/test/resources/tls/ec-key.pem +``` + +- [ ] **Step 6: Generate a second RSA key (does NOT match `rsa-cert.pem`)** + +Run: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out src/test/resources/tls/mismatched-key.pem +``` + +This produces an unencrypted PKCS#8 key by default. + +- [ ] **Step 7: Create a deliberately-broken PEM file** + +Write `src/test/resources/tls/garbage.pem` with this exact content: + +``` +-----BEGIN CERTIFICATE----- +this is not actually base64 encoded certificate data at all +-----END CERTIFICATE----- +``` + +- [ ] **Step 8: Sanity-check both happy-path fixtures** + +Run: + +```bash +openssl x509 -in src/test/resources/tls/rsa-cert.pem -noout -subject -dates +openssl x509 -in src/test/resources/tls/ec-cert.pem -noout -subject -dates +openssl pkey -in src/test/resources/tls/rsa-key.pem -noout -text 2>&1 | head -3 +openssl pkey -in src/test/resources/tls/ec-key.pem -noout -text 2>&1 | head -3 +``` + +Expected: subject `CN = localhost`, validity ~10 years, key text confirms RSA 2048 and EC P-256 respectively. + +- [ ] **Step 9: Commit the fixtures** + +```bash +git add src/test/resources/tls/ +SKIP=commitlint git commit -m "test: Add TLS PEM fixtures for HTTPS support" +``` + +--- + +## Task 2: PemSslContext — RSA happy path (TDD) + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/internal/PemSslContext.java` +- Create: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` + +- [ ] **Step 1: Write the failing RSA happy-path test** + +Write `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java`: + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.Test; + +class PemSslContextTest { + + private static final Path RSA_CERT = Path.of("src/test/resources/tls/rsa-cert.pem"); + private static final Path RSA_KEY = Path.of("src/test/resources/tls/rsa-key.pem"); + + @Test + void loadsRsaPemPair() throws Exception { + SSLContext context = PemSslContext.load(RSA_CERT, RSA_KEY); + + assertThat(context).isNotNull(); + assertThat(context.getProtocol()).isEqualTo("TLS"); + assertThat(context.getServerSocketFactory()).isNotNull(); + } +} +``` + +- [ ] **Step 2: Run the test, confirm it fails** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: compilation failure — `PemSslContext` does not exist. + +- [ ] **Step 3: Create the minimal `PemSslContext`** + +Write `src/main/java/com/retailsvc/http/internal/PemSslContext.java`: + +```java +package com.retailsvc.http.internal; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Collection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +/** Loads a {@link SSLContext} from a PEM certificate chain and PEM PKCS#8 private key. */ +public final class PemSslContext { + + private PemSslContext() {} + + public static SSLContext load(Path certChainPem, Path privateKeyPem) { + Certificate[] chain = readCertificateChain(certChainPem); + PrivateKey key = readPrivateKey(privateKeyPem); + return buildSslContext(chain, key); + } + + private static Certificate[] readCertificateChain(Path path) { + byte[] bytes; + try { + bytes = Files.readAllBytes(path); + } catch (IOException e) { + throw new IllegalStateException("Cannot read TLS certificate chain: " + path, e); + } + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + Collection certs = + factory.generateCertificates(new java.io.ByteArrayInputStream(bytes)); + return certs.toArray(new Certificate[0]); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS certificate chain from " + path, e); + } + } + + private static PrivateKey readPrivateKey(Path path) { + String pem; + try { + pem = Files.readString(path); + } catch (IOException e) { + throw new IllegalStateException("Cannot read TLS private key: " + path, e); + } + byte[] der; + try { + String base64 = + pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + der = Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + try { + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (InvalidKeySpecException rsaFail) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, rsaFail); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + } + + private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("server", key, new char[0], chain); + KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); + kmf.init(ks, new char[0]); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), null, null); + return ctx; + } catch (GeneralSecurityException | IOException e) { + throw new IllegalStateException("TLS certificate and private key do not match", e); + } + } +} +``` + +- [ ] **Step 4: Run the test, confirm it passes** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/PemSslContext.java \ + src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +SKIP=commitlint git commit -m "feat: Add PemSslContext for loading PEM cert + RSA key" +``` + +--- + +## Task 3: PemSslContext — EC support (TDD) + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/internal/PemSslContext.java` +- Modify: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` + +- [ ] **Step 1: Add the failing EC test** + +Append to `PemSslContextTest`: + +```java + private static final Path EC_CERT = Path.of("src/test/resources/tls/ec-cert.pem"); + private static final Path EC_KEY = Path.of("src/test/resources/tls/ec-key.pem"); + + @Test + void loadsEcPemPair() throws Exception { + SSLContext context = PemSslContext.load(EC_CERT, EC_KEY); + + assertThat(context).isNotNull(); + assertThat(context.getServerSocketFactory()).isNotNull(); + } +``` + +- [ ] **Step 2: Run, confirm it fails** + +```bash +mvn test -Dtest=PemSslContextTest#loadsEcPemPair +``` + +Expected: FAIL — `InvalidKeySpecException` wrapped in `IllegalStateException` from RSA `KeyFactory` rejecting EC bytes. + +- [ ] **Step 3: Extend `readPrivateKey` with EC fallback** + +In `PemSslContext.java`, replace the entire `readPrivateKey` method with: + +```java + private static PrivateKey readPrivateKey(Path path) { + String pem; + try { + pem = Files.readString(path); + } catch (IOException e) { + throw new IllegalStateException("Cannot read TLS private key: " + path, e); + } + byte[] der; + try { + String base64 = + pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + der = Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + try { + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (InvalidKeySpecException rsaFail) { + try { + return KeyFactory.getInstance("EC").generatePrivate(spec); + } catch (InvalidKeySpecException ecFail) { + throw new IllegalStateException( + "Unsupported TLS private key algorithm in " + path, ecFail); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + } +``` + +- [ ] **Step 4: Run both happy-path tests** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/PemSslContext.java \ + src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +SKIP=commitlint git commit -m "feat: Support EC private keys in PemSslContext" +``` + +--- + +## Task 4: PemSslContext — error paths (TDD) + +**Files:** + +- Modify: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` + +The error messages are already produced by Task 2/3's implementation. This task just asserts them. + +- [ ] **Step 1: Add the failing missing-file test** + +Append to `PemSslContextTest`: + +```java + private static final Path MISMATCHED_KEY = Path.of("src/test/resources/tls/mismatched-key.pem"); + private static final Path GARBAGE = Path.of("src/test/resources/tls/garbage.pem"); + private static final Path MISSING = Path.of("src/test/resources/tls/does-not-exist.pem"); + + @Test + void rejectsMissingCertFile() { + assertThatThrownBy(() -> PemSslContext.load(MISSING, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot read TLS certificate chain") + .hasMessageContaining("does-not-exist.pem"); + } + + @Test + void rejectsMissingKeyFile() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, MISSING)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot read TLS private key") + .hasMessageContaining("does-not-exist.pem"); + } + + @Test + void rejectsGarbageCertPem() { + assertThatThrownBy(() -> PemSslContext.load(GARBAGE, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to parse TLS certificate chain"); + } + + @Test + void rejectsGarbageKeyPem() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, GARBAGE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to parse TLS private key"); + } + + @Test + void rejectsMismatchedCertAndKey() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, MISMATCHED_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("do not match"); + } +``` + +Add the import at the top of the file: + +```java +import static org.assertj.core.api.Assertions.assertThatThrownBy; +``` + +- [ ] **Step 2: Run, confirm all five pass** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: 7 tests pass (2 happy + 5 negative). + +If the mismatched-key test fails because the key parsed cleanly but the cert→key binding wasn't detected, that's a real bug in `buildSslContext` — `KeyManagerFactory.init` is what surfaces the mismatch, and it does. Verify by reading the actual message: it should contain "do not match" via the chained cause. If `KeyManagerFactory` accepts the pair, the test will fail; do NOT relax the assertion — debug instead. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +SKIP=commitlint git commit -m "test: Cover PemSslContext error paths" +``` + +--- + +## Task 5: Add `Builder.https(...)` and wire `HttpsServer` (TDD) + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` +- Create: `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java` + +This task adds the public builder method, switches the constructor to create `HttpsServer` when HTTPS is configured, and proves it end-to-end with a real HTTPS round-trip. + +- [ ] **Step 1: Add an OpenAPI fixture for the HTTPS IT** + +Reuse the existing `src/test/resources/openapi.json` (or whichever spec the existing `OpenApiServerIT` uses). Confirm the spec path used by the existing IT: + +```bash +grep -l "Spec.from\|fromPath\|fromJson\|fromYaml" src/test/java/com/retailsvc/http/OpenApiServerIT.java +``` + +Read the resolved test spec path and reuse it in the new IT. + +- [ ] **Step 2: Write the failing integration test** + +Write `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java`: + +```java +package com.retailsvc.http; + +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.Spec; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Map; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class OpenApiServerHttpsIT { + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "rsa, src/test/resources/tls/rsa-cert.pem, src/test/resources/tls/rsa-key.pem", + "ec, src/test/resources/tls/ec-cert.pem, src/test/resources/tls/ec-key.pem" + }) + void servesHttpsTraffic(String algo, String certPath, String keyPath) throws Exception { + Path cert = Path.of(certPath); + Path key = Path.of(keyPath); + + Spec spec; + try (InputStream in = getClass().getResourceAsStream("/openapi.json")) { + spec = Spec.fromJson(in); + } + + RequestHandler handler = req -> Response.ok(Map.of("hello", "world")); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getThings", handler)) + .port(0) + .https(cert, key) + .build()) { + + HttpClient client = HttpClient.newBuilder() + .version(HTTP_1_1) + .sslContext(trustStoreFor(cert)) + .build(); + + HttpResponse response = + client.send( + HttpRequest.newBuilder( + URI.create("https://localhost:" + server.listenPort() + "/things")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("\"hello\":\"world\""); + } + } + + private static SSLContext trustStoreFor(Path certPath) throws Exception { + byte[] bytes = Files.readAllBytes(certPath); + Certificate cert = + CertificateFactory.getInstance("X.509") + .generateCertificate(new java.io.ByteArrayInputStream(bytes)); + KeyStore trust = KeyStore.getInstance("PKCS12"); + trust.load(null, null); + trust.setCertificateEntry("server", cert); + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trust); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(new KeyManager[0], tmf.getTrustManagers(), null); + return ctx; + } +} +``` + +NOTE: This IT assumes the test spec at `/openapi.json` declares an operationId `getThings` on `GET /things` with no required parameters, no required body, and no security. If the existing test spec uses different operationIds, adjust the `handler` map and the URI path to match an operation that returns a `Response.ok(...)` cleanly. Confirm by skimming `src/test/resources/openapi.json` before running. + +- [ ] **Step 3: Run, confirm it fails to compile** + +```bash +mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false +``` + +Expected: compilation failure — `OpenApiServer.Builder.https(Path, Path)` does not exist. + +- [ ] **Step 4: Add HTTPS fields and the public method on `Builder`** + +In `src/main/java/com/retailsvc/http/OpenApiServer.java`: + +1. Add the import: + +```java +import java.nio.file.Path; +import com.retailsvc.http.internal.PemSslContext; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import javax.net.ssl.SSLContext; +``` + +2. Change the existing `port` field: + +```java + private int port = DEFAULT_PORT; +``` + +to: + +```java + private Integer port; +``` + +3. Add two new fields next to `port`: + +```java + private Path httpsCertChain; + private Path httpsPrivateKey; +``` + +4. Add the new constant next to `DEFAULT_PORT`: + +```java + private static final int DEFAULT_HTTPS_PORT = 8443; +``` + +5. Update `Builder.port(int)`'s Javadoc to mention the new default behaviour, and assign as before: + +```java + /** + * Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} for HTTP and {@value + * #DEFAULT_HTTPS_PORT} when {@link #https(Path, Path)} is set. Use {@code 0} to bind on an + * ephemeral port (read it back via {@link OpenApiServer#listenPort()}). + */ + public Builder port(int port) { + this.port = port; + return this; + } +``` + +6. Add the new builder method directly after `bindAddress(...)`: + +```java + /** + * Enables HTTPS using the given PEM-encoded certificate chain and PKCS#8 private key. Both + * files must exist when {@link #build()} runs; failures surface as {@link + * IllegalStateException} with the offending path. The certificate file is a PEM concatenation + * of the server certificate followed by any intermediates (matches certbot's {@code + * fullchain.pem}). The private key is an unencrypted PKCS#8 PEM (matches certbot's {@code + * privkey.pem}); RSA and EC keys are both accepted. + * + *

When set, the default port changes from {@value #DEFAULT_PORT} to {@value + * #DEFAULT_HTTPS_PORT}; {@link #port(int)} still overrides. + */ + public Builder https(Path certificateChainPem, Path privateKeyPem) { + this.httpsCertChain = requireNonNull(certificateChainPem, "certificateChainPem must not be null"); + this.httpsPrivateKey = requireNonNull(privateKeyPem, "privateKeyPem must not be null"); + return this; + } +``` + +7. In `Builder.build()`, replace the final `return new OpenApiServer(...)` block with port resolution + SSLContext load + the new constructor call: + +Replace: + +```java + return new OpenApiServer( + spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); +``` + +with: + +```java + int resolvedPort = + port != null ? port : (httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT); + SSLContext sslContext = + httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null; + return new OpenApiServer( + spec, resolved, handlerConfig, resolvedPort, bindAddress, shutdownTimeoutSeconds, sslContext); +``` + +- [ ] **Step 5: Update the constructor to optionally build an `HttpsServer`** + +In `OpenApiServer.java`, change the constructor signature from: + +```java + OpenApiServer( + Spec spec, + Map bodyMappers, + HandlerConfig handlerConfig, + int port, + InetAddress bindAddress, + int shutdownTimeoutSeconds) + throws IOException { +``` + +to: + +```java + OpenApiServer( + Spec spec, + Map bodyMappers, + HandlerConfig handlerConfig, + int port, + InetAddress bindAddress, + int shutdownTimeoutSeconds, + SSLContext sslContext) + throws IOException { +``` + +Replace the block: + +```java + this.httpServer = HttpServer.create(socketAddress, 0); +``` + +with: + +```java + if (sslContext != null) { + HttpsServer https = HttpsServer.create(socketAddress, 0); + https.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + this.httpServer = https; + } else { + this.httpServer = HttpServer.create(socketAddress, 0); + } +``` + +- [ ] **Step 6: Run unit tests, confirm nothing regressed** + +```bash +mvn test +``` + +Expected: every existing test still passes. The `Builder` change from `int port = DEFAULT_PORT` to `Integer port` plus default-resolution in `build()` is observationally identical to the old behaviour for HTTP callers. + +- [ ] **Step 7: Run the new HTTPS IT** + +```bash +mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false +``` + +Expected: both `[rsa]` and `[ec]` parameterised cases pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java \ + src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java +SKIP=commitlint git commit -m "feat: Enable HTTPS via Builder.https(certChain, privateKey)" +``` + +--- + +## Task 6: README documentation + +**Files:** + +- Modify: `README.md` + +- [ ] **Step 1: Add the table-of-contents entry** + +In the `## Table of contents` block, under `## Server configuration`, change: + +```markdown +- [Server configuration](#server-configuration) +``` + +to add a nested HTTPS link (mirroring the existing nesting style if present, otherwise just add a sibling link directly below): + +```markdown +- [Server configuration](#server-configuration) + - [HTTPS](#https) +``` + +Confirm the existing TOC's indentation style first by reading the top of `README.md`; match it. + +- [ ] **Step 2: Add the HTTPS subsection** + +In `README.md`, find the existing `### Graceful shutdown` heading inside `## Server configuration`. Immediately *before* it, insert: + +````markdown +### HTTPS + +Point the builder at a PEM certificate chain and a PEM PKCS#8 private key: + +```java +import java.nio.file.Path; + +var server = OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .https( + Path.of("/etc/letsencrypt/live/example.com/fullchain.pem"), + Path.of("/etc/letsencrypt/live/example.com/privkey.pem")) + .build(); +``` + +certbot / Let's Encrypt write exactly these two files to +`/etc/letsencrypt/live//`: `fullchain.pem` (your certificate + the +issuing intermediates, concatenated PEM) and `privkey.pem` (unencrypted PKCS#8). +No conversion to PKCS12 / JKS is needed; the library parses the PEM directly +using JDK APIs only. + +Both RSA and EC (P-256) private keys are accepted; the algorithm is detected +automatically. + +When `.https(...)` is set, the default port changes from `8080` to `8443`. +`port(int)` still overrides explicitly: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .https(certChain, privateKey) + .port(443) // overrides the 8443 default + .build(); +``` + +For local development without a real certificate, generate a self-signed pair +with one openssl command: + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout privkey.pem -out fullchain.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +Clients (browsers, `curl`, `HttpClient`) need to trust the resulting certificate +explicitly — it isn't signed by a public CA. + +**Not in this release** (each can land later without breaking the API): + +- Encrypted / password-protected private keys +- PKCS12 / JKS keystore inputs +- Certificate hot-reload on renewal (restart the process after `certbot renew`) +- TLS protocol / cipher overrides (JDK defaults apply: TLS 1.2 and 1.3) +- Serving HTTP and HTTPS from one `OpenApiServer` instance +```` + +- [ ] **Step 3: Verify the README renders the new section** + +```bash +grep -n "^### HTTPS$" README.md +``` + +Expected: one line, between the `### Bind address` and `### Graceful shutdown` headings inside `## Server configuration`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +SKIP=commitlint git commit -m "docs: Document HTTPS support in README" +``` + +--- + +## Task 7: Full verification + +- [ ] **Step 1: Clean build, all tests** + +```bash +mvn clean verify +``` + +Expected: BUILD SUCCESS. Surefire and Failsafe report no failures. Jacoco report at `target/site/jacoco/` includes the new `PemSslContext` class with full line coverage (every branch is exercised by Task 4's negative tests). + +- [ ] **Step 2: Run SonarLint over touched files** + +Per the project's pre-push checklist (see `~/.claude/projects/.../memory/feedback_sonar_pre_push.md`), analyse: + +- `src/main/java/com/retailsvc/http/internal/PemSslContext.java` +- `src/main/java/com/retailsvc/http/OpenApiServer.java` +- `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` +- `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java` + +Fix any new issues raised by SonarLint MCP in the same branch before pushing. NOTE: SonarLint MCP is blind to worktrees (the `/workspace` mount is the main repo); CI scan will cover the branch on push. + +- [ ] **Step 3: Push the branch** + +```bash +git push -u origin feat/https-support +``` + +PR opening is manual per the repo's `gh` policy. From e5f50beac124b56f8cdbc7003a049d4135a3ec78 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:32:49 +0200 Subject: [PATCH 03/15] test: Add TLS PEM fixtures for HTTPS support --- .../plans/2026-05-21-https-support.md | 18 ++++++------ src/test/resources/tls/ec-cert.pem | 11 ++++++++ src/test/resources/tls/ec-key.pem | 5 ++++ src/test/resources/tls/garbage.pem | 3 ++ src/test/resources/tls/mismatched-key.pem | 28 +++++++++++++++++++ src/test/resources/tls/rsa-cert.pem | 19 +++++++++++++ src/test/resources/tls/rsa-key.pem | 28 +++++++++++++++++++ 7 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 src/test/resources/tls/ec-cert.pem create mode 100644 src/test/resources/tls/ec-key.pem create mode 100644 src/test/resources/tls/garbage.pem create mode 100644 src/test/resources/tls/mismatched-key.pem create mode 100644 src/test/resources/tls/rsa-cert.pem create mode 100644 src/test/resources/tls/rsa-key.pem diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index 38bc04c..a0129db 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -50,7 +50,7 @@ These are deterministic test fixtures, not secrets. They're committed once and reused. -- [ ] **Step 1: Ensure the fixture directory exists** +- [x] **Step 1: Ensure the fixture directory exists** Run: @@ -58,7 +58,7 @@ Run: mkdir -p src/test/resources/tls ``` -- [ ] **Step 2: Generate the RSA self-signed cert + PKCS#8 key** +- [x] **Step 2: Generate the RSA self-signed cert + PKCS#8 key** Run from the repo root: @@ -72,7 +72,7 @@ openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ `-nodes` makes the key unencrypted. `openssl req` writes PKCS#8 (`-----BEGIN PRIVATE KEY-----`) by default on modern OpenSSL (1.1.1+). `subjectAltName` is required for Java's `HttpClient` to accept the cert without warning. -- [ ] **Step 3: Verify the RSA key is PKCS#8 unencrypted** +- [x] **Step 3: Verify the RSA key is PKCS#8 unencrypted** Run: @@ -88,7 +88,7 @@ openssl pkcs8 -topk8 -nocrypt -in src/test/resources/tls/rsa-key.pem \ && mv src/test/resources/tls/rsa-key.pem.tmp src/test/resources/tls/rsa-key.pem ``` -- [ ] **Step 4: Generate the EC (P-256) self-signed cert + PKCS#8 key** +- [x] **Step 4: Generate the EC (P-256) self-signed cert + PKCS#8 key** Run: @@ -100,7 +100,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" ``` -- [ ] **Step 5: Verify the EC key is PKCS#8 unencrypted** +- [x] **Step 5: Verify the EC key is PKCS#8 unencrypted** Run: @@ -116,7 +116,7 @@ openssl pkcs8 -topk8 -nocrypt -in src/test/resources/tls/ec-key.pem \ && mv src/test/resources/tls/ec-key.pem.tmp src/test/resources/tls/ec-key.pem ``` -- [ ] **Step 6: Generate a second RSA key (does NOT match `rsa-cert.pem`)** +- [x] **Step 6: Generate a second RSA key (does NOT match `rsa-cert.pem`)** Run: @@ -127,7 +127,7 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ This produces an unencrypted PKCS#8 key by default. -- [ ] **Step 7: Create a deliberately-broken PEM file** +- [x] **Step 7: Create a deliberately-broken PEM file** Write `src/test/resources/tls/garbage.pem` with this exact content: @@ -137,7 +137,7 @@ this is not actually base64 encoded certificate data at all -----END CERTIFICATE----- ``` -- [ ] **Step 8: Sanity-check both happy-path fixtures** +- [x] **Step 8: Sanity-check both happy-path fixtures** Run: @@ -150,7 +150,7 @@ openssl pkey -in src/test/resources/tls/ec-key.pem -noout -text 2>&1 | head -3 Expected: subject `CN = localhost`, validity ~10 years, key text confirms RSA 2048 and EC P-256 respectively. -- [ ] **Step 9: Commit the fixtures** +- [x] **Step 9: Commit the fixtures** ```bash git add src/test/resources/tls/ diff --git a/src/test/resources/tls/ec-cert.pem b/src/test/resources/tls/ec-cert.pem new file mode 100644 index 0000000..c5ee904 --- /dev/null +++ b/src/test/resources/tls/ec-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBmTCCAT+gAwIBAgIUUuAW3n1W16lvRDk4YhVufmUP3cswCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyMTA3MzE1N1oXDTM2MDUxODA3 +MzE1N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEMO762s3HsHL7NcUnOsxgB3T16F+9GngVF6c0eV3+AMtgHSev13DimMG7 +G9IYM6nRi72ry0WTrj75/Fteq+UZzaNvMG0wHQYDVR0OBBYEFJ6O8HE5ocETBKhy +5SMbUiqyNd2vMB8GA1UdIwQYMBaAFJ6O8HE5ocETBKhy5SMbUiqyNd2vMA8GA1Ud +EwEB/wQFMAMBAf8wGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMAoGCCqGSM49 +BAMCA0gAMEUCIQDT1+RT/oWzHZmCX+uDhkyx7Dp+Th53REbNYmwy3eSLCgIgC9B9 +s2pJC0OJKBzDAuE6q8teluBpxjaw+uBiXased0w= +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/ec-key.pem b/src/test/resources/tls/ec-key.pem new file mode 100644 index 0000000..8977250 --- /dev/null +++ b/src/test/resources/tls/ec-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgpm3qKHPykk2K2wlr +UEYX831QX1y1opx26Iv3m4EF3/ShRANCAAQw7vrazcewcvs1xSc6zGAHdPXoX70a +eBUXpzR5Xf4Ay2AdJ6/XcOKYwbsb0hgzqdGLvavLRZOuPvn8W16r5RnN +-----END PRIVATE KEY----- diff --git a/src/test/resources/tls/garbage.pem b/src/test/resources/tls/garbage.pem new file mode 100644 index 0000000..6808377 --- /dev/null +++ b/src/test/resources/tls/garbage.pem @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +this is not actually base64 encoded certificate data at all +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/mismatched-key.pem b/src/test/resources/tls/mismatched-key.pem new file mode 100644 index 0000000..270cf0d --- /dev/null +++ b/src/test/resources/tls/mismatched-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDM01Ssl34rRJEH +puMh3M+bQWcI2Bnb5sqZuZPI4/RgjeIdq0SVsdF04f5GwPBv+M1gcVjD6mMmlqP7 +d36tAHuhs3qGRx3ArGEPqaYBy6wK72oeHtR/vgkf7B2XiqmF40xgMfkWpV5ctrTE +4IjUU5MwS1YOeE/l9R1BrxbtC5dtfd0YO61hMqfd/u4o9cGW7emludsgopgbmgpa +b0fZVoYq6qJgr6Xq56QNHi49mboEvpFiv1UHTEzRXubSHdKCKgJe8DN2jzQ180NN +BGJ4AW5SHO46QRuHrIOzhXdWJ0G1AcLrh+MKZEEQuaDg21DUs4buIyA++LfwZWl9 +vA3o9GGbAgMBAAECggEABPEf+7b/E5xgIwePWSqTrotiMXZmqQ4ro5BW21CdfZgY +YA2dUOmRcwOZiydp1sLX+wniItNw4Qyi8cPa83ttqt96JQTqLVuscQsiqAFvP1IH +iUKTPAhS7QlZNdT7irV5z06gBwSMvyEvH5IgPjoqY7152tXa5UCDtdPmNpt50r8Q +LxABenr9e9PrJSW2UCJgCe2S1/CFsChZLz5FrN5L/sjgK2C0WD2gESaP9eI2h1BR +nvloRU0BVfXDf6kmLtJsbaQueXbUmmO2HNcccdmynW6Ap7887blv+WnWoDE1vBji +nA+Smqn+5fAQEBriNJ/gfB12SRORmWiH+DLM1q7U6QKBgQDtsT4784KglVVyHhKs +dAPIvibdTndmP5mNP3QiYcYQgBryfanOrNLIbdMPPJiNnWIkuyujKTvQ0UNQ7h27 +ltLVtcwt/6RgjCpZkeNhZ0f63+EdW9MYAE5MkJ3+bAfBVdhcOICU3pozKvpXIpHI +6phvTO6aNi7WP4MbWVgbF3Z38wKBgQDcmgcTKC2+TsPpLF/pvs8YhIh/6Pg62nrD +nn+hcrY7LRc17NJpo/E+iR0qwBZf9XZ9bE0YHFcLeFhEOvFw/8gr+cJnjpz0DPIR +P16ZLPVHOhQwx9MIKOfO4ED1VDRKUWLj79iWRP3XUN8MEKYC4t3LvPla1V256+bs +RlyaZFFBuQKBgQCI97raBx9986+yO9wc3hmUIub4Xg/1rq6IM0lzyo280mU02O7x +9qrb7lVSEWQDLu7wJZ8mvUsHsJ6u0xf1EhtaJRGMbCTHsd1xkdzKMx7KVRo+tbw1 +t29nNWqlwpDReutbcP+/SWroE1sAvR3u/ihq8pUH7jMdKKofJ2Pa8LFGuwKBgQCY +Em4yHI58FFwlT3vG7MxiwGpAkt8b4ySh2Y9uQl+xJ4JKoDjkrilNzMOYhtZlzBak +m5YBuveeZpWiY2exWAIrnn8PWFaGPq5YiXCy8zUapsToY7fsdhZmnFzrQeLSIIyl +SN/rpx+94HgCHy/x6WawXlMe9NspoZ+M3WxU2jbRsQKBgB60mokLEJJNNBJ4rfqB ++lzT5Yt7a9z8yxhRhGv0lJ1xzt+KaKZC30yujwheE83omW5Q8RR7nsc/cPD8wAoy +Q+GswtBMEy02jHFJUtl7JZwzpsO6Sg2V/9mEKw5+kPrUT9XoTdrQRn3IWvbr3x8R +3Xg8iJqw39t4FSOW2ZwKl4Hn +-----END PRIVATE KEY----- diff --git a/src/test/resources/tls/rsa-cert.pem b/src/test/resources/tls/rsa-cert.pem new file mode 100644 index 0000000..248d4fc --- /dev/null +++ b/src/test/resources/tls/rsa-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUF62pYLdthTkgNw94eFEo0QTjXm0wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyMTA3MzE0OVoXDTM2MDUx +ODA3MzE0OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmdNd21DRCVICYf/5KOXy78dES8eKQ5GEEVo/T0mnUUee +nvkQg4qB1RGKSsBZShyqX2MoY2wq9viStfiErk5DIpIdJGFZ04cx9ACRzXy3B8/P +xFCO/1UAxQ34ipLMbXPqpsdcacoUnNnTXKGohavP1KC4ZKuOMsb1M/72EWOivJuJ +aAOjP4NZpGfdMCM6tulPwBA+kGAMShHfL00sA4L4FFSn/lxmhzeFhU1ij4rGqway +DIrb33ovb5p4I1EwdTJ33yQr9pmRnUQesN8ftK1wQnH37kRCjULzAcfPnmD20qSk +YtXlXDVjncrohz6uoHuZ8BIGgjzx5Yse7C0xxaDISQIDAQABo28wbTAdBgNVHQ4E +FgQU2ICOPduZ7az8vS6Q1EZ6vQOuGjkwHwYDVR0jBBgwFoAU2ICOPduZ7az8vS6Q +1EZ6vQOuGjkwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBACu6TZBkm2O3YZ+Ac1J5nFm8vs0y2j1O +HEpCNVlbUkYqxrXR8jlZiG6iWF+U/ACdcYnDJQJznHJAvS3HNPQwNNz6D+jzlwNw +4ltv0kOWVa9RtpLVp99i1KBIorJoJi1y93DVorYWdMYJNrsaz6STyEylUTlUIbsM +teIIMSqCrjjFUgoPZalyAVGNTZaT1wWLnc4KeBCQ0gDz2kcLho32frvWsqLtRBdL +ikZWwQCPorMVnxTPl6yntIqfxYO7HghlluyIrukH9yR9aURwPQTtoAZchOPACkpA +JfDdHtm+4Oqr5zgwvNQ+AIGi7QMDBAajrk563+jj8G+nP+pA8PUHj/U= +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/rsa-key.pem b/src/test/resources/tls/rsa-key.pem new file mode 100644 index 0000000..cc4fb25 --- /dev/null +++ b/src/test/resources/tls/rsa-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCZ013bUNEJUgJh +//ko5fLvx0RLx4pDkYQRWj9PSadRR56e+RCDioHVEYpKwFlKHKpfYyhjbCr2+JK1 ++ISuTkMikh0kYVnThzH0AJHNfLcHz8/EUI7/VQDFDfiKksxtc+qmx1xpyhSc2dNc +oaiFq8/UoLhkq44yxvUz/vYRY6K8m4loA6M/g1mkZ90wIzq26U/AED6QYAxKEd8v +TSwDgvgUVKf+XGaHN4WFTWKPisarBrIMitvfei9vmngjUTB1MnffJCv2mZGdRB6w +3x+0rXBCcffuREKNQvMBx8+eYPbSpKRi1eVcNWOdyuiHPq6ge5nwEgaCPPHlix7s +LTHFoMhJAgMBAAECggEADHVuGKFHDPYzczSEPafCMWF2Spzyud5DUBR7JDTW9GJU +mpOZns3NDjDJfpxtnu8LbYZecw8Du9UOLObf+C4midpDufqYh+VfSl/xby82s89F +CKVtr4h2837aLn8NR6An1TI4bekMGlgaIlqFh64Ouy35QU4TylEK8xcnXuku+Q+P +ouac4fcYSxbOr7+9oOFRgvTph5shW46i6Gb3h0z3QQ5nvBTtCfvh71uITrFl00Eh +pGAxI9UmYMCf6v3eeThuxi1Y8CvcPsQwyJrKLheLpRTC7Au4tc5ZjuuJ2/c5mNA/ +iRUWWIiaWHiOj6Nvk/uUy5V/PhFJkdQ4yWzbew6X8QKBgQDX8gyjX2MAlmxxdEDT +wpK0ymKynCMkvPgbUsYWJNylOURi1IQu0J9FE20ATr08Mr01OIStuIUDYao0G8V8 +/yvp7zQpRGL5Dy6d6IOtUYpC7wnyAztFonw0eMjyWLS4OsWhoLsLNaD8fHHjzRf8 ++rqnFLsmoUwhOgFmz1Ea+RPTSwKBgQC2W56nfe7aNAeujXenKmfaORTtKK3oMXc1 +697dnygIfEL4EeQf2B4jvKhC5LMWHB+U3r0vTvlPRDI19w/HLrMFnMjkJGGYnwok +h8A1MLP5NUqzHjkuOuHIgWs+6WgR1bVS8n8yPDyuElpqYniHO+IXvnv4l8zB264/ +RIVtCWmCOwKBgQCI6UbIAf8T7Usd93XujItVIofG9CV38nNfZ3B9s6tM+ez4uAKC +Z/TC59kQ+9sQop7Bcm70cWurxC0UUpM4d9QQwn8QtvIFNHH63eM2bN87JrDohtH6 +iVU0M2w78q/JqkGJAw3zwnGqAwB2TJ8r3o+exQ8PI+7PliYzV7f1YPwH1wKBgQC2 +BiQKmgi2I+NbF/jMkuaRDBNYWxekQuP0ndmbLIfgWHDcf7dPFgGXuHPYOcKNGktn +5SHAPDtdJPxdo5xLPNETlBluqOYGWxHbvulb7p5m7gv/WPbIr5u58X0kkgUAcGqL +PmU6tqf3NXis4lfe3SZkfw3t6VBIXUjD/FiWqyN3CwKBgQCX3NoqW0MnXzppPKfy +7wAsZ9ErhEODFq12DWYf582vmx2VlFEiGp8RUOog4Iw4BJpjbQW1Pxo4MNc5Egqk +c9Vg+vK8sKpVzhWkJ0GvCWFxH4jUt401bwICyRktF8RGbl8bnnbrtE7eOjk3JZeS +VRGtDYZsCadvmsAH7o4u7hzdVQ== +-----END PRIVATE KEY----- From 9929b71a002319caee71e5c68861ea267a397a5b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:35:43 +0200 Subject: [PATCH 04/15] feat: Add PemSslContext for loading PEM cert + RSA key --- .../plans/2026-05-21-https-support.md | 10 +-- .../http/internal/PemSslContext.java | 89 +++++++++++++++++++ .../http/internal/PemSslContextTest.java | 22 +++++ 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/internal/PemSslContext.java create mode 100644 src/test/java/com/retailsvc/http/internal/PemSslContextTest.java diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index a0129db..d8c3934 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -166,7 +166,7 @@ SKIP=commitlint git commit -m "test: Add TLS PEM fixtures for HTTPS support" - Create: `src/main/java/com/retailsvc/http/internal/PemSslContext.java` - Create: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` -- [ ] **Step 1: Write the failing RSA happy-path test** +- [x] **Step 1: Write the failing RSA happy-path test** Write `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java`: @@ -195,7 +195,7 @@ class PemSslContextTest { } ``` -- [ ] **Step 2: Run the test, confirm it fails** +- [x] **Step 2: Run the test, confirm it fails** ```bash mvn test -Dtest=PemSslContextTest @@ -203,7 +203,7 @@ mvn test -Dtest=PemSslContextTest Expected: compilation failure — `PemSslContext` does not exist. -- [ ] **Step 3: Create the minimal `PemSslContext`** +- [x] **Step 3: Create the minimal `PemSslContext`** Write `src/main/java/com/retailsvc/http/internal/PemSslContext.java`: @@ -298,7 +298,7 @@ public final class PemSslContext { } ``` -- [ ] **Step 4: Run the test, confirm it passes** +- [x] **Step 4: Run the test, confirm it passes** ```bash mvn test -Dtest=PemSslContextTest @@ -306,7 +306,7 @@ mvn test -Dtest=PemSslContextTest Expected: 1 test passes. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/java/com/retailsvc/http/internal/PemSslContext.java \ diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java new file mode 100644 index 0000000..8d50441 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -0,0 +1,89 @@ +package com.retailsvc.http.internal; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Collection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +/** Loads a {@link SSLContext} from a PEM certificate chain and PEM PKCS#8 private key. */ +public final class PemSslContext { + + private PemSslContext() {} + + public static SSLContext load(Path certChainPem, Path privateKeyPem) { + Certificate[] chain = readCertificateChain(certChainPem); + PrivateKey key = readPrivateKey(privateKeyPem); + return buildSslContext(chain, key); + } + + private static Certificate[] readCertificateChain(Path path) { + byte[] bytes; + try { + bytes = Files.readAllBytes(path); + } catch (IOException e) { + throw new IllegalStateException("Cannot read TLS certificate chain: " + path, e); + } + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + Collection certs = + factory.generateCertificates(new ByteArrayInputStream(bytes)); + return certs.toArray(new Certificate[0]); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS certificate chain from " + path, e); + } + } + + private static PrivateKey readPrivateKey(Path path) { + String pem; + try { + pem = Files.readString(path); + } catch (IOException e) { + throw new IllegalStateException("Cannot read TLS private key: " + path, e); + } + byte[] der; + try { + String base64 = + pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + der = Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + try { + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (InvalidKeySpecException rsaFail) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, rsaFail); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } + } + + private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("server", key, new char[0], chain); + KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); + kmf.init(ks, new char[0]); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), null, null); + return ctx; + } catch (GeneralSecurityException | IOException e) { + throw new IllegalStateException("TLS certificate and private key do not match", e); + } + } +} diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java new file mode 100644 index 0000000..13a78ab --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -0,0 +1,22 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.Test; + +class PemSslContextTest { + + private static final Path RSA_CERT = Path.of("src/test/resources/tls/rsa-cert.pem"); + private static final Path RSA_KEY = Path.of("src/test/resources/tls/rsa-key.pem"); + + @Test + void loadsRsaPemPair() throws Exception { + SSLContext context = PemSslContext.load(RSA_CERT, RSA_KEY); + + assertThat(context).isNotNull(); + assertThat(context.getProtocol()).isEqualTo("TLS"); + assertThat(context.getServerSocketFactory()).isNotNull(); + } +} From 088d972621ccca12e8e115dfbdf80d1fc99f11fb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:39:11 +0200 Subject: [PATCH 05/15] feat: Support EC private keys in PemSslContext --- docs/superpowers/plans/2026-05-21-https-support.md | 10 +++++----- .../com/retailsvc/http/internal/PemSslContext.java | 8 +++++++- .../com/retailsvc/http/internal/PemSslContextTest.java | 10 ++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index d8c3934..4b248f7 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -323,7 +323,7 @@ SKIP=commitlint git commit -m "feat: Add PemSslContext for loading PEM cert + RS - Modify: `src/main/java/com/retailsvc/http/internal/PemSslContext.java` - Modify: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java` -- [ ] **Step 1: Add the failing EC test** +- [x] **Step 1: Add the failing EC test** Append to `PemSslContextTest`: @@ -340,7 +340,7 @@ Append to `PemSslContextTest`: } ``` -- [ ] **Step 2: Run, confirm it fails** +- [x] **Step 2: Run, confirm it fails** ```bash mvn test -Dtest=PemSslContextTest#loadsEcPemPair @@ -348,7 +348,7 @@ mvn test -Dtest=PemSslContextTest#loadsEcPemPair Expected: FAIL — `InvalidKeySpecException` wrapped in `IllegalStateException` from RSA `KeyFactory` rejecting EC bytes. -- [ ] **Step 3: Extend `readPrivateKey` with EC fallback** +- [x] **Step 3: Extend `readPrivateKey` with EC fallback** In `PemSslContext.java`, replace the entire `readPrivateKey` method with: @@ -388,7 +388,7 @@ In `PemSslContext.java`, replace the entire `readPrivateKey` method with: } ``` -- [ ] **Step 4: Run both happy-path tests** +- [x] **Step 4: Run both happy-path tests** ```bash mvn test -Dtest=PemSslContextTest @@ -396,7 +396,7 @@ mvn test -Dtest=PemSslContextTest Expected: 2 tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/java/com/retailsvc/http/internal/PemSslContext.java \ diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index 8d50441..56e12b7 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -66,7 +66,13 @@ private static PrivateKey readPrivateKey(Path path) { try { return KeyFactory.getInstance("RSA").generatePrivate(spec); } catch (InvalidKeySpecException rsaFail) { - throw new IllegalStateException("Failed to parse TLS private key from " + path, rsaFail); + try { + return KeyFactory.getInstance("EC").generatePrivate(spec); + } catch (InvalidKeySpecException ecFail) { + throw new IllegalStateException("Unsupported TLS private key algorithm in " + path, ecFail); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + } } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to parse TLS private key from " + path, e); } diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index 13a78ab..d8266e9 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -10,6 +10,8 @@ class PemSslContextTest { private static final Path RSA_CERT = Path.of("src/test/resources/tls/rsa-cert.pem"); private static final Path RSA_KEY = Path.of("src/test/resources/tls/rsa-key.pem"); + private static final Path EC_CERT = Path.of("src/test/resources/tls/ec-cert.pem"); + private static final Path EC_KEY = Path.of("src/test/resources/tls/ec-key.pem"); @Test void loadsRsaPemPair() throws Exception { @@ -19,4 +21,12 @@ void loadsRsaPemPair() throws Exception { assertThat(context.getProtocol()).isEqualTo("TLS"); assertThat(context.getServerSocketFactory()).isNotNull(); } + + @Test + void loadsEcPemPair() throws Exception { + SSLContext context = PemSslContext.load(EC_CERT, EC_KEY); + + assertThat(context).isNotNull(); + assertThat(context.getServerSocketFactory()).isNotNull(); + } } From 1efd6ac4986658850113216df0093b6e1e3b274e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:45:38 +0200 Subject: [PATCH 06/15] test: Cover PemSslContext error paths --- .../plans/2026-05-21-https-support.md | 6 +-- .../http/internal/PemSslContext.java | 33 +++++++++++++++ .../http/internal/PemSslContextTest.java | 41 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index 4b248f7..0bfedc3 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -414,7 +414,7 @@ SKIP=commitlint git commit -m "feat: Support EC private keys in PemSslContext" The error messages are already produced by Task 2/3's implementation. This task just asserts them. -- [ ] **Step 1: Add the failing missing-file test** +- [x] **Step 1: Add the failing missing-file test** Append to `PemSslContextTest`: @@ -467,7 +467,7 @@ Add the import at the top of the file: import static org.assertj.core.api.Assertions.assertThatThrownBy; ``` -- [ ] **Step 2: Run, confirm all five pass** +- [x] **Step 2: Run, confirm all five pass** ```bash mvn test -Dtest=PemSslContextTest @@ -477,7 +477,7 @@ Expected: 7 tests pass (2 happy + 5 negative). If the mismatched-key test fails because the key parsed cleanly but the cert→key binding wasn't detected, that's a real bug in `buildSslContext` — `KeyManagerFactory.init` is what surfaces the mismatch, and it does. Verify by reading the actual message: it should contain "do not match" via the chained cause. If `KeyManagerFactory` accepts the pair, the test will fail; do NOT relax the assertion — debug instead. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add src/test/java/com/retailsvc/http/internal/PemSslContextTest.java diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index 56e12b7..9dee7f8 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -8,6 +8,7 @@ import java.security.KeyFactory; import java.security.KeyStore; import java.security.PrivateKey; +import java.security.Signature; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.security.spec.InvalidKeySpecException; @@ -79,6 +80,7 @@ private static PrivateKey readPrivateKey(Path path) { } private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { + verifyKeyMatchesCert(key, chain[0]); try { KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, null); @@ -92,4 +94,35 @@ private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { throw new IllegalStateException("TLS certificate and private key do not match", e); } } + + private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { + String algorithm = + switch (key.getAlgorithm()) { + case "RSA" -> "SHA256withRSA"; + case "EC" -> "SHA256withECDSA"; + default -> + throw new IllegalStateException( + "Unsupported TLS private key algorithm: " + key.getAlgorithm()); + }; + byte[] probe = {1, 2, 3, 4, 5, 6, 7, 8}; + byte[] signature; + try { + Signature signer = Signature.getInstance(algorithm); + signer.initSign(key); + signer.update(probe); + signature = signer.sign(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("TLS certificate and private key do not match", e); + } + try { + Signature verifier = Signature.getInstance(algorithm); + verifier.initVerify(cert.getPublicKey()); + verifier.update(probe); + if (!verifier.verify(signature)) { + throw new IllegalStateException("TLS certificate and private key do not match"); + } + } catch (GeneralSecurityException e) { + throw new IllegalStateException("TLS certificate and private key do not match", e); + } + } } diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index d8266e9..a4115db 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -1,6 +1,7 @@ package com.retailsvc.http.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.file.Path; import javax.net.ssl.SSLContext; @@ -12,6 +13,9 @@ class PemSslContextTest { private static final Path RSA_KEY = Path.of("src/test/resources/tls/rsa-key.pem"); private static final Path EC_CERT = Path.of("src/test/resources/tls/ec-cert.pem"); private static final Path EC_KEY = Path.of("src/test/resources/tls/ec-key.pem"); + private static final Path MISMATCHED_KEY = Path.of("src/test/resources/tls/mismatched-key.pem"); + private static final Path GARBAGE = Path.of("src/test/resources/tls/garbage.pem"); + private static final Path MISSING = Path.of("src/test/resources/tls/does-not-exist.pem"); @Test void loadsRsaPemPair() throws Exception { @@ -29,4 +33,41 @@ void loadsEcPemPair() throws Exception { assertThat(context).isNotNull(); assertThat(context.getServerSocketFactory()).isNotNull(); } + + @Test + void rejectsMissingCertFile() { + assertThatThrownBy(() -> PemSslContext.load(MISSING, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot read TLS certificate chain") + .hasMessageContaining("does-not-exist.pem"); + } + + @Test + void rejectsMissingKeyFile() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, MISSING)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot read TLS private key") + .hasMessageContaining("does-not-exist.pem"); + } + + @Test + void rejectsGarbageCertPem() { + assertThatThrownBy(() -> PemSslContext.load(GARBAGE, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to parse TLS certificate chain"); + } + + @Test + void rejectsGarbageKeyPem() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, GARBAGE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to parse TLS private key"); + } + + @Test + void rejectsMismatchedCertAndKey() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, MISMATCHED_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("do not match"); + } } From 39fe1eac0d3da898561717f61c234ffc785a6d98 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 09:48:16 +0200 Subject: [PATCH 07/15] refactor: Isolate PEM decoding for future JEP 524 swap --- .../http/internal/PemSslContext.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index 9dee7f8..d2b0888 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -36,13 +36,18 @@ private static Certificate[] readCertificateChain(Path path) { } catch (IOException e) { throw new IllegalStateException("Cannot read TLS certificate chain: " + path, e); } + return decodeCertificateChain(bytes, path); + } + + // JEP 524 swap point: replace this body with PEMDecoder when the JDK PEM API lands. + private static Certificate[] decodeCertificateChain(byte[] pem, Path source) { try { CertificateFactory factory = CertificateFactory.getInstance("X.509"); Collection certs = - factory.generateCertificates(new ByteArrayInputStream(bytes)); + factory.generateCertificates(new ByteArrayInputStream(pem)); return certs.toArray(new Certificate[0]); } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to parse TLS certificate chain from " + path, e); + throw new IllegalStateException("Failed to parse TLS certificate chain from " + source, e); } } @@ -53,6 +58,11 @@ private static PrivateKey readPrivateKey(Path path) { } catch (IOException e) { throw new IllegalStateException("Cannot read TLS private key: " + path, e); } + return decodePrivateKey(pem, path); + } + + // JEP 524 swap point: replace this body with PEMDecoder when the JDK PEM API lands. + private static PrivateKey decodePrivateKey(String pem, Path source) { byte[] der; try { String base64 = @@ -61,7 +71,7 @@ private static PrivateKey readPrivateKey(Path path) { .replaceAll("\\s+", ""); der = Base64.getDecoder().decode(base64); } catch (IllegalArgumentException e) { - throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + throw new IllegalStateException("Failed to parse TLS private key from " + source, e); } PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); try { @@ -70,12 +80,13 @@ private static PrivateKey readPrivateKey(Path path) { try { return KeyFactory.getInstance("EC").generatePrivate(spec); } catch (InvalidKeySpecException ecFail) { - throw new IllegalStateException("Unsupported TLS private key algorithm in " + path, ecFail); + throw new IllegalStateException( + "Unsupported TLS private key algorithm in " + source, ecFail); } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + throw new IllegalStateException("Failed to parse TLS private key from " + source, e); } } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to parse TLS private key from " + path, e); + throw new IllegalStateException("Failed to parse TLS private key from " + source, e); } } From f75476fed8a91c3f13c671c62d06aa2427b853fa Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 10:00:44 +0200 Subject: [PATCH 08/15] feat: Enable HTTPS via Builder.https(certChain, privateKey) --- .../plans/2026-05-21-https-support.md | 16 ++-- .../com/retailsvc/http/OpenApiServer.java | 56 ++++++++++-- .../retailsvc/http/OpenApiServerHttpsIT.java | 88 +++++++++++++++++++ 3 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index 0bfedc3..53faa7e 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -495,7 +495,7 @@ SKIP=commitlint git commit -m "test: Cover PemSslContext error paths" This task adds the public builder method, switches the constructor to create `HttpsServer` when HTTPS is configured, and proves it end-to-end with a real HTTPS round-trip. -- [ ] **Step 1: Add an OpenAPI fixture for the HTTPS IT** +- [x] **Step 1: Add an OpenAPI fixture for the HTTPS IT** Reuse the existing `src/test/resources/openapi.json` (or whichever spec the existing `OpenApiServerIT` uses). Confirm the spec path used by the existing IT: @@ -505,7 +505,7 @@ grep -l "Spec.from\|fromPath\|fromJson\|fromYaml" src/test/java/com/retailsvc/ht Read the resolved test spec path and reuse it in the new IT. -- [ ] **Step 2: Write the failing integration test** +- [x] **Step 2: Write the failing integration test** Write `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java`: @@ -598,7 +598,7 @@ class OpenApiServerHttpsIT { NOTE: This IT assumes the test spec at `/openapi.json` declares an operationId `getThings` on `GET /things` with no required parameters, no required body, and no security. If the existing test spec uses different operationIds, adjust the `handler` map and the URI path to match an operation that returns a `Response.ok(...)` cleanly. Confirm by skimming `src/test/resources/openapi.json` before running. -- [ ] **Step 3: Run, confirm it fails to compile** +- [x] **Step 3: Run, confirm it fails to compile** ```bash mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false @@ -606,7 +606,7 @@ mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false Expected: compilation failure — `OpenApiServer.Builder.https(Path, Path)` does not exist. -- [ ] **Step 4: Add HTTPS fields and the public method on `Builder`** +- [x] **Step 4: Add HTTPS fields and the public method on `Builder`** In `src/main/java/com/retailsvc/http/OpenApiServer.java`: @@ -700,7 +700,7 @@ with: spec, resolved, handlerConfig, resolvedPort, bindAddress, shutdownTimeoutSeconds, sslContext); ``` -- [ ] **Step 5: Update the constructor to optionally build an `HttpsServer`** +- [x] **Step 5: Update the constructor to optionally build an `HttpsServer`** In `OpenApiServer.java`, change the constructor signature from: @@ -747,7 +747,7 @@ with: } ``` -- [ ] **Step 6: Run unit tests, confirm nothing regressed** +- [x] **Step 6: Run unit tests, confirm nothing regressed** ```bash mvn test @@ -755,7 +755,7 @@ mvn test Expected: every existing test still passes. The `Builder` change from `int port = DEFAULT_PORT` to `Integer port` plus default-resolution in `build()` is observationally identical to the old behaviour for HTTP callers. -- [ ] **Step 7: Run the new HTTPS IT** +- [x] **Step 7: Run the new HTTPS IT** ```bash mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false @@ -763,7 +763,7 @@ mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false Expected: both `[rsa]` and `[ec]` parameterised cases pass. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add src/main/java/com/retailsvc/http/OpenApiServer.java \ diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index b778d17..cf89e1c 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -9,6 +9,7 @@ import com.retailsvc.http.internal.ExtraRouteAdapter; import com.retailsvc.http.internal.FormTypeMapper; import com.retailsvc.http.internal.NotFoundHandler; +import com.retailsvc.http.internal.PemSslContext; import com.retailsvc.http.internal.RequestPreparationFilter; import com.retailsvc.http.internal.ResponseRenderer; import com.retailsvc.http.internal.Router; @@ -22,9 +23,12 @@ import com.retailsvc.http.validate.DefaultValidator; import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -35,6 +39,7 @@ import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,6 +52,7 @@ public class OpenApiServer implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(OpenApiServer.class); private static final int DEFAULT_PORT = 8080; + private static final int DEFAULT_HTTPS_PORT = 8443; private static final String JSON = "application/json"; private static final String GSON_CLASS = "com.google.gson.Gson"; @@ -70,7 +76,8 @@ record HandlerConfig( HandlerConfig handlerConfig, int port, InetAddress bindAddress, - int shutdownTimeoutSeconds) + int shutdownTimeoutSeconds, + SSLContext sslContext) throws IOException { requireNonNull(spec, "Spec must not be null"); @@ -89,7 +96,13 @@ record HandlerConfig( (bindAddress == null) ? new InetSocketAddress(port) : new InetSocketAddress(bindAddress, port); - this.httpServer = HttpServer.create(socketAddress, 0); + if (sslContext != null) { + HttpsServer https = HttpsServer.create(socketAddress, 0); + https.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + this.httpServer = https; + } else { + this.httpServer = HttpServer.create(socketAddress, 0); + } httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory())); ResponseRenderer renderer = new ResponseRenderer(bodyMappers); @@ -189,7 +202,9 @@ public static final class Builder { private final List interceptors = new ArrayList<>(); private final List afterHooks = new ArrayList<>(); private ExceptionHandler exceptionHandler; - private int port = DEFAULT_PORT; + private Integer port; + private Path httpsCertChain; + private Path httpsPrivateKey; private InetAddress bindAddress; private int shutdownTimeoutSeconds = 0; private final LinkedHashMap extras = new LinkedHashMap<>(); @@ -279,8 +294,9 @@ public Builder exceptionHandler(ExceptionHandler exceptionHandler) { } /** - * Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} when not set. Use {@code - * 0} to bind on an ephemeral port (read it back via {@link OpenApiServer#listenPort()}). + * Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} for HTTP and {@value + * #DEFAULT_HTTPS_PORT} when {@link #https(Path, Path)} is set. Use {@code 0} to bind on an + * ephemeral port (read it back via {@link OpenApiServer#listenPort()}). */ public Builder port(int port) { this.port = port; @@ -297,6 +313,24 @@ public Builder bindAddress(InetAddress bindAddress) { return this; } + /** + * Enables HTTPS using the given PEM-encoded certificate chain and PKCS#8 private key. Both + * files must exist when {@link #build()} runs; failures surface as {@link + * IllegalStateException} with the offending path. The certificate file is a PEM concatenation + * of the server certificate followed by any intermediates (matches certbot's {@code + * fullchain.pem}). The private key is an unencrypted PKCS#8 PEM (matches certbot's {@code + * privkey.pem}); RSA and EC keys are both accepted. + * + *

When set, the default port changes from {@value #DEFAULT_PORT} to {@value + * #DEFAULT_HTTPS_PORT}; {@link #port(int)} still overrides. + */ + public Builder https(Path certificateChainPem, Path privateKeyPem) { + this.httpsCertChain = + requireNonNull(certificateChainPem, "certificateChainPem must not be null"); + this.httpsPrivateKey = requireNonNull(privateKeyPem, "privateKeyPem must not be null"); + return this; + } + /** * Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default) * stops immediately; positive values wait up to that many seconds for in-flight exchanges to @@ -354,8 +388,18 @@ public OpenApiServer build() throws IOException { Map.copyOf(securityValidators), externalAuth, List.copyOf(afterHooks)); + int resolvedPort = + port != null ? port : (httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT); + SSLContext sslContext = + httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null; return new OpenApiServer( - spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); + spec, + resolved, + handlerConfig, + resolvedPort, + bindAddress, + shutdownTimeoutSeconds, + sslContext); } private static void validateHandlerWiring(Spec spec, Map handlers) { diff --git a/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java new file mode 100644 index 0000000..c44db93 --- /dev/null +++ b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java @@ -0,0 +1,88 @@ +package com.retailsvc.http; + +import static com.retailsvc.http.ServerBaseTest.stubAllHandlers; +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.Spec; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class OpenApiServerHttpsIT { + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "rsa, src/test/resources/tls/rsa-cert.pem, src/test/resources/tls/rsa-key.pem", + "ec, src/test/resources/tls/ec-cert.pem, src/test/resources/tls/ec-key.pem" + }) + void servesHttpsTraffic(String algo, String certPath, String keyPath) throws Exception { + Path cert = Path.of(certPath); + Path key = Path.of(keyPath); + + Spec spec; + try (InputStream in = getClass().getResourceAsStream("/openapi.json")) { + spec = Spec.fromJson(in); + } + + RequestHandler handler = req -> Response.ok(Map.of("hello", "world")); + Map handlers = stubAllHandlers(spec, Map.of("get-data", handler)); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .port(0) + .https(cert, key) + .build()) { + + HttpClient client = + HttpClient.newBuilder().version(HTTP_1_1).sslContext(trustStoreFor(cert)).build(); + + HttpResponse response = + client.send( + HttpRequest.newBuilder( + URI.create("https://localhost:" + server.listenPort() + "/api/v1/data")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("\"hello\":\"world\""); + } + } + + private static SSLContext trustStoreFor(Path certPath) throws Exception { + byte[] bytes = Files.readAllBytes(certPath); + Certificate cert = + CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(bytes)); + KeyStore trust = KeyStore.getInstance("PKCS12"); + trust.load(null, null); + trust.setCertificateEntry("server", cert); + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trust); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(new KeyManager[0], tmf.getTrustManagers(), null); + return ctx; + } +} From ad80372e9680f83261869f94c14c726479af1181 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 10:03:09 +0200 Subject: [PATCH 09/15] docs: Document HTTPS support in README --- README.md | 59 +++++++++++++++++++ .../plans/2026-05-21-https-support.md | 8 +-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 11d94ee..5c3408e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function - [JSON mapping](#json-mapping) - [Body parsers and response writers](#body-parsers-and-response-writers) - [Server configuration](#server-configuration) + - [HTTPS](#https) - [Interceptors and response decorators](#interceptors-and-response-decorators) - [After-response hooks](#after-response-hooks) - [Security](#security) @@ -326,6 +327,64 @@ OpenApiServer.builder() .build(); ``` +### HTTPS + +Point the builder at a PEM certificate chain and a PEM PKCS#8 private key: + +```java +import java.nio.file.Path; + +var server = OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .https( + Path.of("/etc/letsencrypt/live/example.com/fullchain.pem"), + Path.of("/etc/letsencrypt/live/example.com/privkey.pem")) + .build(); +``` + +certbot / Let's Encrypt write exactly these two files to +`/etc/letsencrypt/live//`: `fullchain.pem` (your certificate + the +issuing intermediates, concatenated PEM) and `privkey.pem` (unencrypted PKCS#8). +No conversion to PKCS12 / JKS is needed; the library parses the PEM directly +using JDK APIs only. + +Both RSA and EC (P-256) private keys are accepted; the algorithm is detected +automatically. + +When `.https(...)` is set, the default port changes from `8080` to `8443`. +`port(int)` still overrides explicitly: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .https(certChain, privateKey) + .port(443) // overrides the 8443 default + .build(); +``` + +For local development without a real certificate, generate a self-signed pair +with one openssl command: + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout privkey.pem -out fullchain.pem \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +Clients (browsers, `curl`, `HttpClient`) need to trust the resulting certificate +explicitly — it isn't signed by a public CA. + +**Not in this release** (each can land later without breaking the API): + +- Encrypted / password-protected private keys +- PKCS12 / JKS keystore inputs +- Certificate hot-reload on renewal (restart the process after `certbot renew`) +- TLS protocol / cipher overrides (JDK defaults apply: TLS 1.2 and 1.3) +- Serving HTTP and HTTPS from one `OpenApiServer` instance + ### Graceful shutdown `OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the given diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index 53faa7e..d371573 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -779,7 +779,7 @@ SKIP=commitlint git commit -m "feat: Enable HTTPS via Builder.https(certChain, p - Modify: `README.md` -- [ ] **Step 1: Add the table-of-contents entry** +- [x] **Step 1: Add the table-of-contents entry** In the `## Table of contents` block, under `## Server configuration`, change: @@ -796,7 +796,7 @@ to add a nested HTTPS link (mirroring the existing nesting style if present, oth Confirm the existing TOC's indentation style first by reading the top of `README.md`; match it. -- [ ] **Step 2: Add the HTTPS subsection** +- [x] **Step 2: Add the HTTPS subsection** In `README.md`, find the existing `### Graceful shutdown` heading inside `## Server configuration`. Immediately *before* it, insert: @@ -860,7 +860,7 @@ explicitly — it isn't signed by a public CA. - Serving HTTP and HTTPS from one `OpenApiServer` instance ```` -- [ ] **Step 3: Verify the README renders the new section** +- [x] **Step 3: Verify the README renders the new section** ```bash grep -n "^### HTTPS$" README.md @@ -868,7 +868,7 @@ grep -n "^### HTTPS$" README.md Expected: one line, between the `### Bind address` and `### Graceful shutdown` headings inside `## Server configuration`. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add README.md From 0ecf7251a9d7957ddf823b0c45153da1ba2308fd Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 10:04:11 +0200 Subject: [PATCH 10/15] docs: Recommend mounting HTTPS PEMs from a secret manager --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 5c3408e..1749a1b 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,25 @@ using JDK APIs only. Both RSA and EC (P-256) private keys are accepted; the algorithm is detected automatically. +**Deployment.** Don't bake `privkey.pem` into your container image — you +lose rotation and leak the key into image layers and registries. Mount the +two PEM files at runtime from a secret manager: + +- **Kubernetes:** [cert-manager](https://cert-manager.io) writes the + certificate and key into a `Secret`; mount it as a volume at the path you + pass to `.https(...)`. Renewal is automatic; restart the pod (e.g. via a + rolling deploy keyed off the Secret's revision) to pick up the new cert. +- **GCP:** Store both files in Secret Manager and project them with the + [Secret Manager CSI driver](https://cloud.google.com/secret-manager/docs/access-control) + or a Workload Identity-bound init container that writes the files to an + `emptyDir` shared with the app container. +- **AWS:** [Secrets Manager](https://docs.aws.amazon.com/secretsmanager/) via + the [AWS Secrets and Configuration Provider](https://github.com/aws/secrets-store-csi-driver-provider-aws) + for the CSI driver follows the same pattern. + +Whatever the source: mount the volume read-only, give `privkey.pem` mode +`0400` (owner-read only), and ensure the JVM process owns or can read it. + When `.https(...)` is set, the default port changes from `8080` to `8443`. `port(int)` still overrides explicitly: From 819d292e1dbdc88f30a04bba9aae86d5b553d45f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 10:06:47 +0200 Subject: [PATCH 11/15] docs: Check off Task 7 verification steps --- docs/superpowers/plans/2026-05-21-https-support.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-21-https-support.md b/docs/superpowers/plans/2026-05-21-https-support.md index d371573..552e087 100644 --- a/docs/superpowers/plans/2026-05-21-https-support.md +++ b/docs/superpowers/plans/2026-05-21-https-support.md @@ -879,7 +879,7 @@ SKIP=commitlint git commit -m "docs: Document HTTPS support in README" ## Task 7: Full verification -- [ ] **Step 1: Clean build, all tests** +- [x] **Step 1: Clean build, all tests** ```bash mvn clean verify @@ -887,7 +887,7 @@ mvn clean verify Expected: BUILD SUCCESS. Surefire and Failsafe report no failures. Jacoco report at `target/site/jacoco/` includes the new `PemSslContext` class with full line coverage (every branch is exercised by Task 4's negative tests). -- [ ] **Step 2: Run SonarLint over touched files** +- [x] **Step 2: Run SonarLint over touched files** (skipped — SonarLint MCP blind to worktrees per repo memory; CI scan will cover branch on push) Per the project's pre-push checklist (see `~/.claude/projects/.../memory/feedback_sonar_pre_push.md`), analyse: From 6b0c59bd211b5cf8ad2b83dbff2ee696bd26ef92 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 10:15:03 +0200 Subject: [PATCH 12/15] feat: Pin TLS 1.2/1.3, enforce min key size, guard empty chain --- .../com/retailsvc/http/OpenApiServer.java | 23 ++++++++- .../http/internal/PemSslContext.java | 49 +++++++++++++++++-- .../retailsvc/http/OpenApiServerHttpsIT.java | 35 +++++++++++++ .../http/internal/PemSslContextTest.java | 21 ++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index cf89e1c..1b77f67 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -24,6 +24,7 @@ import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; import com.sun.net.httpserver.HttpsServer; import java.io.IOException; import java.net.InetAddress; @@ -40,6 +41,7 @@ import java.util.TreeSet; import java.util.stream.Collectors; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,7 +100,7 @@ record HandlerConfig( : new InetSocketAddress(bindAddress, port); if (sslContext != null) { HttpsServer https = HttpsServer.create(socketAddress, 0); - https.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + https.setHttpsConfigurator(new TlsHttpsConfigurator(sslContext)); this.httpServer = https; } else { this.httpServer = HttpServer.create(socketAddress, 0); @@ -475,4 +477,23 @@ private static TypeMapper tryLoadGsonMapper() { return new GsonJsonMapper(); } } + + /** + * Pins HTTPS to TLS 1.2 and 1.3 only, regardless of operator-level {@code java.security} + * overrides, and explicitly leaves client-cert auth off (no mTLS in v1). + */ + private static final class TlsHttpsConfigurator extends HttpsConfigurator { + TlsHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + SSLParameters sslParams = getSSLContext().getDefaultSSLParameters(); + sslParams.setProtocols(new String[] {"TLSv1.3", "TLSv1.2"}); + sslParams.setNeedClientAuth(false); + sslParams.setWantClientAuth(false); + params.setSSLParameters(sslParams); + } + } } diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index d2b0888..80e3a30 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -8,9 +8,13 @@ import java.security.KeyFactory; import java.security.KeyStore; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.Signature; import java.security.cert.Certificate; +import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; @@ -43,9 +47,24 @@ private static Certificate[] readCertificateChain(Path path) { private static Certificate[] decodeCertificateChain(byte[] pem, Path source) { try { CertificateFactory factory = CertificateFactory.getInstance("X.509"); - Collection certs = - factory.generateCertificates(new ByteArrayInputStream(pem)); - return certs.toArray(new Certificate[0]); + Collection certs; + try { + certs = factory.generateCertificates(new ByteArrayInputStream(pem)); + } catch (CertificateException e) { + // JDK X509Factory throws "No certificate data found" for input with no + // BEGIN CERTIFICATE block. Treat that as an empty chain rather than a parse error. + if (e.getMessage() != null && e.getMessage().contains("No certificate data found")) { + throw new IllegalStateException( + "No certificates found in TLS certificate chain: " + source); + } + throw e; + } + Certificate[] chain = certs.toArray(new Certificate[0]); + if (chain.length == 0) { + throw new IllegalStateException( + "No certificates found in TLS certificate chain: " + source); + } + return chain; } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to parse TLS certificate chain from " + source, e); } @@ -92,6 +111,7 @@ private static PrivateKey decodePrivateKey(String pem, Path source) { private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { verifyKeyMatchesCert(key, chain[0]); + requireMinimumStrength(key, chain[0]); try { KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, null); @@ -106,6 +126,29 @@ private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { } } + private static void requireMinimumStrength(PrivateKey key, Certificate cert) { + PublicKey publicKey = cert.getPublicKey(); + switch (publicKey) { + case RSAPublicKey rsa -> { + int bits = rsa.getModulus().bitLength(); + if (bits < 2048) { + throw new IllegalStateException( + "TLS RSA key below minimum strength: " + bits + " bits (require >= 2048)"); + } + } + case ECPublicKey ec -> { + int bits = ec.getParams().getCurve().getField().getFieldSize(); + if (bits < 256) { + throw new IllegalStateException( + "TLS EC key below minimum strength: " + bits + " bits (require >= 256)"); + } + } + default -> + throw new IllegalStateException( + "Unsupported TLS public key algorithm: " + publicKey.getAlgorithm()); + } + } + private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { String algorithm = switch (key.getAlgorithm()) { diff --git a/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java index c44db93..7b800ec 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java @@ -20,7 +20,9 @@ import java.util.Optional; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -70,6 +72,39 @@ void servesHttpsTraffic(String algo, String certPath, String keyPath) throws Exc } } + @Test + void negotiatesTls13() throws Exception { + Path cert = Path.of("src/test/resources/tls/rsa-cert.pem"); + Path key = Path.of("src/test/resources/tls/rsa-key.pem"); + + Spec spec; + try (InputStream in = getClass().getResourceAsStream("/openapi.json")) { + spec = Spec.fromJson(in); + } + + RequestHandler handler = req -> Response.ok(Map.of("hello", "world")); + Map handlers = stubAllHandlers(spec, Map.of("get-data", handler)); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .port(0) + .https(cert, key) + .build()) { + + SSLContext clientCtx = trustStoreFor(cert); + try (SSLSocket socket = + (SSLSocket) clientCtx.getSocketFactory().createSocket("localhost", server.listenPort())) { + socket.startHandshake(); + assertThat(socket.getSession().getProtocol()).isEqualTo("TLSv1.3"); + } + } + } + private static SSLContext trustStoreFor(Path certPath) throws Exception { byte[] bytes = Files.readAllBytes(certPath); Certificate cert = diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index a4115db..a9fe0cc 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.file.Files; import java.nio.file.Path; import javax.net.ssl.SSLContext; import org.junit.jupiter.api.Test; @@ -70,4 +71,24 @@ void rejectsMismatchedCertAndKey() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("do not match"); } + + @Test + void rejectsEmptyCertificateChain() throws Exception { + Path emptyCert = Files.createTempFile("empty-chain", ".pem"); + Files.writeString(emptyCert, "# no certificates here\n"); + try { + assertThatThrownBy(() -> PemSslContext.load(emptyCert, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No certificates found in TLS certificate chain"); + } finally { + Files.deleteIfExists(emptyCert); + } + } + + @Test + void acceptsEcKeyAtMinimumStrength() throws Exception { + // P-256 (256 bits) is exactly at the floor — must pass. + SSLContext ctx = PemSslContext.load(EC_CERT, EC_KEY); + assertThat(ctx).isNotNull(); + } } From d1dceb7dd39bd70a7ef582f198f0970534ed24a0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 11:05:26 +0200 Subject: [PATCH 13/15] test: Cover weak RSA path and split resolvePort helper Adds a 1024-bit RSA fixture and PemSslContextTest case that exercises the minimum-strength guard, lifting new-code coverage on PR 91 above the 80% Sonar gate. Splits the nested ternary in OpenApiServer.Builder into a resolvePort() helper to clear the matching SonarLint finding. --- .../java/com/retailsvc/http/OpenApiServer.java | 10 ++++++++-- .../http/internal/PemSslContextTest.java | 10 ++++++++++ src/test/resources/tls/weak-rsa-cert.pem | 14 ++++++++++++++ src/test/resources/tls/weak-rsa-key.pem | 16 ++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/tls/weak-rsa-cert.pem create mode 100644 src/test/resources/tls/weak-rsa-key.pem diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 1b77f67..c06df23 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -390,8 +390,7 @@ public OpenApiServer build() throws IOException { Map.copyOf(securityValidators), externalAuth, List.copyOf(afterHooks)); - int resolvedPort = - port != null ? port : (httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT); + int resolvedPort = resolvePort(); SSLContext sslContext = httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null; return new OpenApiServer( @@ -404,6 +403,13 @@ public OpenApiServer build() throws IOException { sslContext); } + private int resolvePort() { + if (port != null) { + return port; + } + return httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT; + } + private static void validateHandlerWiring(Spec spec, Map handlers) { Set specOps = new TreeSet<>(); for (Operation op : spec.operations()) { diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index a9fe0cc..c55d637 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -17,6 +17,8 @@ class PemSslContextTest { private static final Path MISMATCHED_KEY = Path.of("src/test/resources/tls/mismatched-key.pem"); private static final Path GARBAGE = Path.of("src/test/resources/tls/garbage.pem"); private static final Path MISSING = Path.of("src/test/resources/tls/does-not-exist.pem"); + private static final Path WEAK_RSA_CERT = Path.of("src/test/resources/tls/weak-rsa-cert.pem"); + private static final Path WEAK_RSA_KEY = Path.of("src/test/resources/tls/weak-rsa-key.pem"); @Test void loadsRsaPemPair() throws Exception { @@ -91,4 +93,12 @@ void acceptsEcKeyAtMinimumStrength() throws Exception { SSLContext ctx = PemSslContext.load(EC_CERT, EC_KEY); assertThat(ctx).isNotNull(); } + + @Test + void rejectsWeakRsaKey() { + assertThatThrownBy(() -> PemSslContext.load(WEAK_RSA_CERT, WEAK_RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TLS RSA key below minimum strength") + .hasMessageContaining("1024 bits"); + } } diff --git a/src/test/resources/tls/weak-rsa-cert.pem b/src/test/resources/tls/weak-rsa-cert.pem new file mode 100644 index 0000000..2cc029d --- /dev/null +++ b/src/test/resources/tls/weak-rsa-cert.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGDCCAYGgAwIBAgIUROGXccQOoJe1ANi92SDG+ghoRe4wDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSd2Vhay1yc2EtbG9jYWxob3N0MCAXDTI2MDUyMTA4NTUw +MVoYDzIxMjYwNDI3MDg1NTAxWjAdMRswGQYDVQQDDBJ3ZWFrLXJzYS1sb2NhbGhv +c3QwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAK50Oy2AQ9HLGNRf8ncZ11Y1 +H9hewBbSqPW5vw0To//KxdzpSAprW1C37ZXBHTT1eiDqgr247GxTWBo6axCtLHq3 +uh5ODdqcXSdGxu2KbMu2Pa+0IYJvmgrwN8uBT+j0FtjCQxokni3l7Qhs7amNnTag +ToSlGUzQE2nhAeV+IyohAgMBAAGjUzBRMB0GA1UdDgQWBBTV4Fiz8I5WXeQxzPU3 +pJLLVLhKaTAfBgNVHSMEGDAWgBTV4Fiz8I5WXeQxzPU3pJLLVLhKaTAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4GBAD4K1YYaJ00lKiQKHF/gKpoRzgzl +8P5qbChHEtv46Nj4govB2QuoW0d1SHVSS15bnQ90doeEDF/MExossoC8W4b4i+7I +LeyMMOB5wCFQntIslci+Ec/5z9mrWqf8EK4V6j2DIXKaEpQszApU0NnASwgmvyfP +6r6aiu7Z4Izibc6P +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/weak-rsa-key.pem b/src/test/resources/tls/weak-rsa-key.pem new file mode 100644 index 0000000..831e4c1 --- /dev/null +++ b/src/test/resources/tls/weak-rsa-key.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAK50Oy2AQ9HLGNRf +8ncZ11Y1H9hewBbSqPW5vw0To//KxdzpSAprW1C37ZXBHTT1eiDqgr247GxTWBo6 +axCtLHq3uh5ODdqcXSdGxu2KbMu2Pa+0IYJvmgrwN8uBT+j0FtjCQxokni3l7Qhs +7amNnTagToSlGUzQE2nhAeV+IyohAgMBAAECgYAakY8VrewmPlUouvgVVXUrJuoT +rNJ6Z1jeG4zSNASNB1e8/jY/h/wfPfPME94b26re6nhA5rHzCXpofC8kGguk4Gwz +FFSC+x21L37xFIGvSwv89DvL9cRA7RXZmhNWzsf5M730EOx5F2ffwyczsp0U0KXU +0nh6Qx5BHDNSMZVHNQJBAOBT4YDibOINikJx7ciKje9MgQTcUztXhOWcN6jmn4Km +AIo5yiLYgryW0gILpcnhQNMQfXDRc6Cbb7CI0KmNq78CQQDHFbaX/BK1wOVkddCN +XHZxKmEHBV2JdZ+udSNaF8CnxTq3ooRvozJBpxQmbWTMaL6jZIPMSwKRM+ZTWBzV +1CIfAkApXo65rAgUcBbNRiFp2FNwjBVHBjK7QNqbVYHWPiGwgFidJScn4fHKQa4c +/nTmlAnWYrYfdiDyv3eLgM+qVRwVAkEAn6n6VsoC92FMl9Uk/To6g2fJiSf0bFm5 +RuELCSYjjGnRPZVJQX9QvvaQYoE5ZfZbbg8e5KkD1hAZmJ4CAjuvYQJBALI5HaIn +ujOcOSVEK2NOIOH4JdtmoTx2O0IImYX3svje3h41Uj0+zh8bWRvejpdwU4Z7p/YL +rh/zmtMxXQZadgM= +-----END PRIVATE KEY----- From 4145ced2d7c2aa32baed043fae2de54bafd23827 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 13:25:37 +0200 Subject: [PATCH 14/15] refactor: Address Sonar findings on PemSslContext Extracts the nested try in decodeCertificateChain into a helper, chains the RSA InvalidKeySpecException as suppressed when the EC fallback also fails, drops the unused PrivateKey parameter on requireMinimumStrength, names the 2048/256-bit floors and the 8-byte signature probe as constants, and removes redundant `throws Exception` declarations on tests that don't throw. --- .../http/internal/PemSslContext.java | 70 ++++++++++++------- .../http/internal/PemSslContextTest.java | 6 +- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index 80e3a30..f785953 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -25,6 +25,10 @@ /** Loads a {@link SSLContext} from a PEM certificate chain and PEM PKCS#8 private key. */ public final class PemSslContext { + private static final int MIN_RSA_KEY_BITS = 2048; + private static final int MIN_EC_KEY_BITS = 256; + private static final byte[] SIGNATURE_PROBE = {1, 2, 3, 4, 5, 6, 7, 8}; + private PemSslContext() {} public static SSLContext load(Path certChainPem, Path privateKeyPem) { @@ -46,19 +50,7 @@ private static Certificate[] readCertificateChain(Path path) { // JEP 524 swap point: replace this body with PEMDecoder when the JDK PEM API lands. private static Certificate[] decodeCertificateChain(byte[] pem, Path source) { try { - CertificateFactory factory = CertificateFactory.getInstance("X.509"); - Collection certs; - try { - certs = factory.generateCertificates(new ByteArrayInputStream(pem)); - } catch (CertificateException e) { - // JDK X509Factory throws "No certificate data found" for input with no - // BEGIN CERTIFICATE block. Treat that as an empty chain rather than a parse error. - if (e.getMessage() != null && e.getMessage().contains("No certificate data found")) { - throw new IllegalStateException( - "No certificates found in TLS certificate chain: " + source); - } - throw e; - } + Collection certs = parseCertificates(pem, source); Certificate[] chain = certs.toArray(new Certificate[0]); if (chain.length == 0) { throw new IllegalStateException( @@ -70,6 +62,22 @@ private static Certificate[] decodeCertificateChain(byte[] pem, Path source) { } } + private static Collection parseCertificates(byte[] pem, Path source) + throws GeneralSecurityException { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + try { + return factory.generateCertificates(new ByteArrayInputStream(pem)); + } catch (CertificateException e) { + // JDK X509Factory throws "No certificate data found" for input with no + // BEGIN CERTIFICATE block. Treat that as an empty chain rather than a parse error. + if (e.getMessage() != null && e.getMessage().contains("No certificate data found")) { + throw new IllegalStateException( + "No certificates found in TLS certificate chain: " + source, e); + } + throw e; + } + } + private static PrivateKey readPrivateKey(Path path) { String pem; try { @@ -99,10 +107,15 @@ private static PrivateKey decodePrivateKey(String pem, Path source) { try { return KeyFactory.getInstance("EC").generatePrivate(spec); } catch (InvalidKeySpecException ecFail) { - throw new IllegalStateException( - "Unsupported TLS private key algorithm in " + source, ecFail); + IllegalStateException failure = + new IllegalStateException("Unsupported TLS private key algorithm in " + source, ecFail); + failure.addSuppressed(rsaFail); + throw failure; } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to parse TLS private key from " + source, e); + IllegalStateException failure = + new IllegalStateException("Failed to parse TLS private key from " + source, e); + failure.addSuppressed(rsaFail); + throw failure; } } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to parse TLS private key from " + source, e); @@ -111,7 +124,7 @@ private static PrivateKey decodePrivateKey(String pem, Path source) { private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { verifyKeyMatchesCert(key, chain[0]); - requireMinimumStrength(key, chain[0]); + requireMinimumStrength(chain[0]); try { KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, null); @@ -126,21 +139,29 @@ private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { } } - private static void requireMinimumStrength(PrivateKey key, Certificate cert) { + private static void requireMinimumStrength(Certificate cert) { PublicKey publicKey = cert.getPublicKey(); switch (publicKey) { case RSAPublicKey rsa -> { int bits = rsa.getModulus().bitLength(); - if (bits < 2048) { + if (bits < MIN_RSA_KEY_BITS) { throw new IllegalStateException( - "TLS RSA key below minimum strength: " + bits + " bits (require >= 2048)"); + "TLS RSA key below minimum strength: " + + bits + + " bits (require >= " + + MIN_RSA_KEY_BITS + + ")"); } } case ECPublicKey ec -> { int bits = ec.getParams().getCurve().getField().getFieldSize(); - if (bits < 256) { + if (bits < MIN_EC_KEY_BITS) { throw new IllegalStateException( - "TLS EC key below minimum strength: " + bits + " bits (require >= 256)"); + "TLS EC key below minimum strength: " + + bits + + " bits (require >= " + + MIN_EC_KEY_BITS + + ")"); } } default -> @@ -158,12 +179,11 @@ private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { throw new IllegalStateException( "Unsupported TLS private key algorithm: " + key.getAlgorithm()); }; - byte[] probe = {1, 2, 3, 4, 5, 6, 7, 8}; byte[] signature; try { Signature signer = Signature.getInstance(algorithm); signer.initSign(key); - signer.update(probe); + signer.update(SIGNATURE_PROBE); signature = signer.sign(); } catch (GeneralSecurityException e) { throw new IllegalStateException("TLS certificate and private key do not match", e); @@ -171,7 +191,7 @@ private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { try { Signature verifier = Signature.getInstance(algorithm); verifier.initVerify(cert.getPublicKey()); - verifier.update(probe); + verifier.update(SIGNATURE_PROBE); if (!verifier.verify(signature)) { throw new IllegalStateException("TLS certificate and private key do not match"); } diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index c55d637..7bed223 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -21,7 +21,7 @@ class PemSslContextTest { private static final Path WEAK_RSA_KEY = Path.of("src/test/resources/tls/weak-rsa-key.pem"); @Test - void loadsRsaPemPair() throws Exception { + void loadsRsaPemPair() { SSLContext context = PemSslContext.load(RSA_CERT, RSA_KEY); assertThat(context).isNotNull(); @@ -30,7 +30,7 @@ void loadsRsaPemPair() throws Exception { } @Test - void loadsEcPemPair() throws Exception { + void loadsEcPemPair() { SSLContext context = PemSslContext.load(EC_CERT, EC_KEY); assertThat(context).isNotNull(); @@ -88,7 +88,7 @@ void rejectsEmptyCertificateChain() throws Exception { } @Test - void acceptsEcKeyAtMinimumStrength() throws Exception { + void acceptsEcKeyAtMinimumStrength() { // P-256 (256 bits) is exactly at the floor — must pass. SSLContext ctx = PemSslContext.load(EC_CERT, EC_KEY); assertThat(ctx).isNotNull(); From a24bf23ff502ca3ed117df325366cde14652308d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 14:00:33 +0200 Subject: [PATCH 15/15] test: Cover weak EC and unsupported-algorithm paths in PemSslContext Reorders the strength check before the signature check so a sub-spec EC key fails fast on bit count (192 < 256) instead of tripping the matching-signature probe. Adds P-192 EC and DSA fixtures, plus tests covering the weak EC throw, the "Unsupported TLS private key algorithm" branch (DSA key with RSA cert), and the "Unsupported TLS public key algorithm" branch (DSA cert with RSA key). Simplifies the algorithm switch in verifyKeyMatchesCert now that decodePrivateKey guarantees RSA or EC keys. Lifts PemSslContext line coverage from ~77% to 86%. Remaining uncovered lines are defensive GeneralSecurityException / IOException catches around JDK crypto APIs that do not throw for valid inputs. --- .../http/internal/PemSslContext.java | 12 +++------ .../http/internal/PemSslContextTest.java | 26 +++++++++++++++++++ src/test/resources/tls/dsa-cert.pem | 26 +++++++++++++++++++ src/test/resources/tls/dsa-key.pem | 15 +++++++++++ src/test/resources/tls/weak-ec-cert.pem | 10 +++++++ src/test/resources/tls/weak-ec-key.pem | 5 ++++ 6 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 src/test/resources/tls/dsa-cert.pem create mode 100644 src/test/resources/tls/dsa-key.pem create mode 100644 src/test/resources/tls/weak-ec-cert.pem create mode 100644 src/test/resources/tls/weak-ec-key.pem diff --git a/src/main/java/com/retailsvc/http/internal/PemSslContext.java b/src/main/java/com/retailsvc/http/internal/PemSslContext.java index f785953..31d0c92 100644 --- a/src/main/java/com/retailsvc/http/internal/PemSslContext.java +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -123,8 +123,8 @@ private static PrivateKey decodePrivateKey(String pem, Path source) { } private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { - verifyKeyMatchesCert(key, chain[0]); requireMinimumStrength(chain[0]); + verifyKeyMatchesCert(key, chain[0]); try { KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, null); @@ -171,14 +171,8 @@ private static void requireMinimumStrength(Certificate cert) { } private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { - String algorithm = - switch (key.getAlgorithm()) { - case "RSA" -> "SHA256withRSA"; - case "EC" -> "SHA256withECDSA"; - default -> - throw new IllegalStateException( - "Unsupported TLS private key algorithm: " + key.getAlgorithm()); - }; + // decodePrivateKey only returns RSA or EC keys, so this switch is total without a default. + String algorithm = "RSA".equals(key.getAlgorithm()) ? "SHA256withRSA" : "SHA256withECDSA"; byte[] signature; try { Signature signer = Signature.getInstance(algorithm); diff --git a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java index 7bed223..5888632 100644 --- a/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -19,6 +19,10 @@ class PemSslContextTest { private static final Path MISSING = Path.of("src/test/resources/tls/does-not-exist.pem"); private static final Path WEAK_RSA_CERT = Path.of("src/test/resources/tls/weak-rsa-cert.pem"); private static final Path WEAK_RSA_KEY = Path.of("src/test/resources/tls/weak-rsa-key.pem"); + private static final Path WEAK_EC_CERT = Path.of("src/test/resources/tls/weak-ec-cert.pem"); + private static final Path WEAK_EC_KEY = Path.of("src/test/resources/tls/weak-ec-key.pem"); + private static final Path DSA_CERT = Path.of("src/test/resources/tls/dsa-cert.pem"); + private static final Path DSA_KEY = Path.of("src/test/resources/tls/dsa-key.pem"); @Test void loadsRsaPemPair() { @@ -101,4 +105,26 @@ void rejectsWeakRsaKey() { .hasMessageContaining("TLS RSA key below minimum strength") .hasMessageContaining("1024 bits"); } + + @Test + void rejectsWeakEcKey() { + assertThatThrownBy(() -> PemSslContext.load(WEAK_EC_CERT, WEAK_EC_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TLS EC key below minimum strength") + .hasMessageContaining("192 bits"); + } + + @Test + void rejectsUnsupportedKeyAlgorithm() { + assertThatThrownBy(() -> PemSslContext.load(RSA_CERT, DSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsupported TLS private key algorithm"); + } + + @Test + void rejectsUnsupportedCertAlgorithm() { + assertThatThrownBy(() -> PemSslContext.load(DSA_CERT, RSA_KEY)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsupported TLS public key algorithm"); + } } diff --git a/src/test/resources/tls/dsa-cert.pem b/src/test/resources/tls/dsa-cert.pem new file mode 100644 index 0000000..09f3b88 --- /dev/null +++ b/src/test/resources/tls/dsa-cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEazCCBBmgAwIBAgIUNDFdkKEWyHLwpeKHQvzzAgY78UMwCwYJYIZIAWUDBAMC +MBgxFjAUBgNVBAMMDWRzYS1sb2NhbGhvc3QwIBcNMjYwNTIxMTE1ODA1WhgPMjEy +NjA0MjcxMTU4MDVaMBgxFjAUBgNVBAMMDWRzYS1sb2NhbGhvc3QwggNCMIICNQYH +KoZIzjgEATCCAigCggEBAMc/wejLRCRVLn8zzCO0kIqkRhDtRu+PqQNbV+l+Muom +K8dJ6m6ZLdcla7nLYa8cDTA8GurBDFGXv6PFPHlFV8+xJVC9jK9p4hcZZ1aS1ykN +GKV3ha3pVFYVeGRzx2UAlQHR0/bw7LnnwNHAhQ7u1OqRdWKTx4PNiG60j99nb2Sh +Sa+3Im90c2Yt+4PIuQQw5for7wY5iQRfNmlRi7i7F8aeO2rBVIVdainW42IEBC0W +ldSqkx7IyF7Ym/+7HdRowMJ1yh36Zj67p5pkwuxs6h4GiucddYO3i2EomWygA/Xm +PhzBuHMKUdI9aH5j0P6dtrXN8TiwJupFCiPzMGsK49sCHQDdN2HZKEIr1JmcnnIo +8hbgufDqqOMAOhGiKAkVAoIBABGihRZdRmOtvWpgdF/ttaaM0Ds+gffpdQn3zXHe +su8iRQFQqXbF4R31VKiqVmz36HHRLH55CJZc4SXKpW+5loDEfZYtECrwQggvwoYj +f5Ko7Gd/p/iKnTVn+bnsehdqI9csWrjkiqWxDdpq8LYcH2uUQLbJWCuEiMWhEptv +VroCEpMfp66XyGYH+hB5zhklH+/lOnUpoZyiMmBh56brpqP1J+Z5GZTlBFhXhUAl +gnqVS6fCDpZu1mijZNyWZO0kIoLnWTd6eMYGE9AUynpqvtwX0eGze4KjQkAY24mj +M8p2loUH5DeigD4h5aog4oUKfvYoegB9n4S5o+ukpYY6BzUDggEFAAKCAQAFfX4V +q3ei1HhAGaJ/LBp3cwmEvZLuGcfyZCYS8QwIdmAHHut3O2cK7sQ0vQ68kSW6MLCU +rw1P9U9FlyKkgUKI7d1mb+Up8vk7n4qnEU7gaiLge+39SjqPuUyqKuVTJs/ZWxMS +UrMTVwx2hnMO3miTBzZ4hHDGYnKce/VEcR0X/CffZ7NZb93JpD0aVS+BKH4XK/ZR +R1mY/Y3Iah3psbPDmyFs84Y4K4E4gS2wkl1nvhvfAJRDvGEF6T40HCaDnX53OY7H +qIo4pJdtxvsbk1fEJvZuoOPhwy/5pr83wR9O0in8+twJxzKFciAgdV+1eNf/7imd +p3k27MUkmZowblK9o1MwUTAdBgNVHQ4EFgQUcKihfiAes+nytWG9lBkNVv56gwgw +HwYDVR0jBBgwFoAUcKihfiAes+nytWG9lBkNVv56gwgwDwYDVR0TAQH/BAUwAwEB +/zALBglghkgBZQMEAwIDPwAwPAIcbiFkWhn84KIKUR//o0nAGwNMrqZ54KxaW7eR +ZQIcDqkzw8eeGq9U1ClhWYN27vb5gT53OSc/Q6isRg== +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/dsa-key.pem b/src/test/resources/tls/dsa-key.pem new file mode 100644 index 0000000..5ee1e60 --- /dev/null +++ b/src/test/resources/tls/dsa-key.pem @@ -0,0 +1,15 @@ +-----BEGIN PRIVATE KEY----- +MIICXgIBADCCAjYGByqGSM44BAEwggIpAoIBAQDfxy8WtSqub6Lx8wZNtZ9r/EEl +jxOxns9qjtvkjew5SFuQtMwr8y6dTMPf+qumUse1okmxsPU0c3Iof3yGByYe1jUx +AJVEtL0TYdDTvwA+ttNwPnQndFzKAc2MFBxMOTjZIiuhKvBmo6SDgLvflcQbyVdT +g0tCkcui+hBel/M/CnWZEy6/fOY6u7chMv+R1dAFRo1y64VAyTV9+4WU2oIrU26Z +soM/TDJWJxEnNck15dzOXY1O4w0FDi0hTkR9j2Xrumc6R9uvnVsRmuP5TzfHqI7E +eWpSiaNYLOevkIcXHmg+pi0H/8XFdddgT007Sd0Bu6D8rhkAKczedu4Bg75HAh0A +xcvFdwdZZefPAdsfQ13PWJwvVvMBZrJs/FFFEQKCAQEAm/S5T8+WCM9hv8OHayEQ +oqJfa0LkCZMXT4lPqa/NiKDQEGmjtcvc06T1Nmnboq4AqUrlswKojpvu5vdXDk3g +Zf2qzUOZspLEpXlHDJ9XwCNnrb9UBUK4yVXbDeFqsdBPa09CW/5f901bAyz/l7/i +fJYyOwsL6ZQWKWJQbhWweqeoCCVBkPpTQyc4J869K4LHyghA+sXC1/BQMS2EfJVH +IcLkWmyHuPKGOo914bnamg5ofeFHTgUUGWIAr81uw41AEiKrYyT0dkO5o7K7Q0iW +xj7wAiYuFCQkD3QUGTa3LBoBMgEJQGITIgz1RaJy6EP+ANZmah0CSrqeD0tl86hT +YgQfAh0AmIlpe0R/PsdbBmDDKMgnfJM1qnj3hLj0VL0HjA== +-----END PRIVATE KEY----- diff --git a/src/test/resources/tls/weak-ec-cert.pem b/src/test/resources/tls/weak-ec-cert.pem new file mode 100644 index 0000000..894f012 --- /dev/null +++ b/src/test/resources/tls/weak-ec-cert.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBbjCCASWgAwIBAgIUMdzHTBEjEXAMlBO0J6da9KtC1KowCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRd2Vhay1lYy1sb2NhbGhvc3QwIBcNMjYwNTIxMTE0NjI1WhgP +MjEyNjA0MjcxMTQ2MjVaMBwxGjAYBgNVBAMMEXdlYWstZWMtbG9jYWxob3N0MEkw +EwYHKoZIzj0CAQYIKoZIzj0DAQEDMgAEcOYb3OnogqFQUG3Ua6xhiJ2iIftE+xin +TNBUPGbprqaz4fwp/IiyaCxOD1/V6f9Uo1MwUTAdBgNVHQ4EFgQUnI1V6NkJRrt7 +Nj+DebVnQPcDYF4wHwYDVR0jBBgwFoAUnI1V6NkJRrt7Nj+DebVnQPcDYF4wDwYD +VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgM3ADA0Ahhbie15KevgEt4nY1SILbxJ +mbt78OGM5GcCGEKBaKXGDzY6Q/GkcAuT1mvBiKDMAzGYQA== +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/weak-ec-key.pem b/src/test/resources/tls/weak-ec-key.pem new file mode 100644 index 0000000..85162ad --- /dev/null +++ b/src/test/resources/tls/weak-ec-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MG8CAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQEEVTBTAgEBBBj236RAh4V7+Ncz7Qtn +94smhfxZXP13By+hNAMyAARw5hvc6eiCoVBQbdRrrGGInaIh+0T7GKdM0FQ8Zumu +prPh/Cn8iLJoLE4PX9Xp/1Q= +-----END PRIVATE KEY-----