diff --git a/README.md b/README.md index 11d94ee..1749a1b 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,83 @@ 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. + +**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: + +```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 new file mode 100644 index 0000000..552e087 --- /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. + +- [x] **Step 1: Ensure the fixture directory exists** + +Run: + +```bash +mkdir -p src/test/resources/tls +``` + +- [x] **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. + +- [x] **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 +``` + +- [x] **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" +``` + +- [x] **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 +``` + +- [x] **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. + +- [x] **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----- +``` + +- [x] **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. + +- [x] **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` + +- [x] **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(); + } +} +``` + +- [x] **Step 2: Run the test, confirm it fails** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: compilation failure — `PemSslContext` does not exist. + +- [x] **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); + } + } +} +``` + +- [x] **Step 4: Run the test, confirm it passes** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: 1 test passes. + +- [x] **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` + +- [x] **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(); + } +``` + +- [x] **Step 2: Run, confirm it fails** + +```bash +mvn test -Dtest=PemSslContextTest#loadsEcPemPair +``` + +Expected: FAIL — `InvalidKeySpecException` wrapped in `IllegalStateException` from RSA `KeyFactory` rejecting EC bytes. + +- [x] **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); + } + } +``` + +- [x] **Step 4: Run both happy-path tests** + +```bash +mvn test -Dtest=PemSslContextTest +``` + +Expected: 2 tests pass. + +- [x] **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. + +- [x] **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; +``` + +- [x] **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. + +- [x] **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. + +- [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: + +```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. + +- [x] **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. + +- [x] **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. + +- [x] **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); +``` + +- [x] **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); + } +``` + +- [x] **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. + +- [x] **Step 7: Run the new HTTPS IT** + +```bash +mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false +``` + +Expected: both `[rsa]` and `[ec]` parameterised cases pass. + +- [x] **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` + +- [x] **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. + +- [x] **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 +```` + +- [x] **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`. + +- [x] **Step 4: Commit** + +```bash +git add README.md +SKIP=commitlint git commit -m "docs: Document HTTPS support in README" +``` + +--- + +## Task 7: Full verification + +- [x] **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). + +- [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: + +- `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. 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). diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index b778d17..c06df23 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,13 @@ 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.HttpsParameters; +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 +40,8 @@ import java.util.Set; 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; @@ -47,6 +54,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 +78,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 +98,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 TlsHttpsConfigurator(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 +204,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 +296,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 +315,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 +390,24 @@ public OpenApiServer build() throws IOException { Map.copyOf(securityValidators), externalAuth, List.copyOf(afterHooks)); + int resolvedPort = resolvePort(); + 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 int resolvePort() { + if (port != null) { + return port; + } + return httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT; } private static void validateHandlerWiring(Spec spec, Map handlers) { @@ -431,4 +483,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 new file mode 100644 index 0000000..31d0c92 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/PemSslContext.java @@ -0,0 +1,196 @@ +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.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; +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 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) { + 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); + } + 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 { + Collection certs = parseCertificates(pem, source); + 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); + } + } + + 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 { + pem = Files.readString(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 = + 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 " + source, 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) { + IllegalStateException failure = + new IllegalStateException("Unsupported TLS private key algorithm in " + source, ecFail); + failure.addSuppressed(rsaFail); + throw failure; + } catch (GeneralSecurityException 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); + } + } + + private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) { + requireMinimumStrength(chain[0]); + verifyKeyMatchesCert(key, chain[0]); + 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); + } + } + + private static void requireMinimumStrength(Certificate cert) { + PublicKey publicKey = cert.getPublicKey(); + switch (publicKey) { + case RSAPublicKey rsa -> { + int bits = rsa.getModulus().bitLength(); + if (bits < MIN_RSA_KEY_BITS) { + throw new IllegalStateException( + "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 < MIN_EC_KEY_BITS) { + throw new IllegalStateException( + "TLS EC key below minimum strength: " + + bits + + " bits (require >= " + + MIN_EC_KEY_BITS + + ")"); + } + } + default -> + throw new IllegalStateException( + "Unsupported TLS public key algorithm: " + publicKey.getAlgorithm()); + } + } + + private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) { + // 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); + signer.initSign(key); + signer.update(SIGNATURE_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(SIGNATURE_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/OpenApiServerHttpsIT.java b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java new file mode 100644 index 0000000..7b800ec --- /dev/null +++ b/src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java @@ -0,0 +1,123 @@ +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.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; + +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\""); + } + } + + @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 = + 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; + } +} 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..5888632 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/PemSslContextTest.java @@ -0,0 +1,130 @@ +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.Files; +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"); + 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"); + 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() { + SSLContext context = PemSslContext.load(RSA_CERT, RSA_KEY); + + assertThat(context).isNotNull(); + assertThat(context.getProtocol()).isEqualTo("TLS"); + assertThat(context.getServerSocketFactory()).isNotNull(); + } + + @Test + void loadsEcPemPair() { + SSLContext context = PemSslContext.load(EC_CERT, EC_KEY); + + 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"); + } + + @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() { + // P-256 (256 bits) is exactly at the floor — must pass. + 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"); + } + + @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/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----- 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----- 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-----