Skip to content

Commit 6b0c59b

Browse files
committed
feat: Pin TLS 1.2/1.3, enforce min key size, guard empty chain
1 parent 819d292 commit 6b0c59b

4 files changed

Lines changed: 124 additions & 4 deletions

File tree

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.sun.net.httpserver.HttpContext;
2525
import com.sun.net.httpserver.HttpServer;
2626
import com.sun.net.httpserver.HttpsConfigurator;
27+
import com.sun.net.httpserver.HttpsParameters;
2728
import com.sun.net.httpserver.HttpsServer;
2829
import java.io.IOException;
2930
import java.net.InetAddress;
@@ -40,6 +41,7 @@
4041
import java.util.TreeSet;
4142
import java.util.stream.Collectors;
4243
import javax.net.ssl.SSLContext;
44+
import javax.net.ssl.SSLParameters;
4345
import org.slf4j.Logger;
4446
import org.slf4j.LoggerFactory;
4547

@@ -98,7 +100,7 @@ record HandlerConfig(
98100
: new InetSocketAddress(bindAddress, port);
99101
if (sslContext != null) {
100102
HttpsServer https = HttpsServer.create(socketAddress, 0);
101-
https.setHttpsConfigurator(new HttpsConfigurator(sslContext));
103+
https.setHttpsConfigurator(new TlsHttpsConfigurator(sslContext));
102104
this.httpServer = https;
103105
} else {
104106
this.httpServer = HttpServer.create(socketAddress, 0);
@@ -475,4 +477,23 @@ private static TypeMapper tryLoadGsonMapper() {
475477
return new GsonJsonMapper();
476478
}
477479
}
480+
481+
/**
482+
* Pins HTTPS to TLS 1.2 and 1.3 only, regardless of operator-level {@code java.security}
483+
* overrides, and explicitly leaves client-cert auth off (no mTLS in v1).
484+
*/
485+
private static final class TlsHttpsConfigurator extends HttpsConfigurator {
486+
TlsHttpsConfigurator(SSLContext context) {
487+
super(context);
488+
}
489+
490+
@Override
491+
public void configure(HttpsParameters params) {
492+
SSLParameters sslParams = getSSLContext().getDefaultSSLParameters();
493+
sslParams.setProtocols(new String[] {"TLSv1.3", "TLSv1.2"});
494+
sslParams.setNeedClientAuth(false);
495+
sslParams.setWantClientAuth(false);
496+
params.setSSLParameters(sslParams);
497+
}
498+
}
478499
}

src/main/java/com/retailsvc/http/internal/PemSslContext.java

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
import java.security.KeyFactory;
99
import java.security.KeyStore;
1010
import java.security.PrivateKey;
11+
import java.security.PublicKey;
1112
import java.security.Signature;
1213
import java.security.cert.Certificate;
14+
import java.security.cert.CertificateException;
1315
import java.security.cert.CertificateFactory;
16+
import java.security.interfaces.ECPublicKey;
17+
import java.security.interfaces.RSAPublicKey;
1418
import java.security.spec.InvalidKeySpecException;
1519
import java.security.spec.PKCS8EncodedKeySpec;
1620
import java.util.Base64;
@@ -43,9 +47,24 @@ private static Certificate[] readCertificateChain(Path path) {
4347
private static Certificate[] decodeCertificateChain(byte[] pem, Path source) {
4448
try {
4549
CertificateFactory factory = CertificateFactory.getInstance("X.509");
46-
Collection<? extends Certificate> certs =
47-
factory.generateCertificates(new ByteArrayInputStream(pem));
48-
return certs.toArray(new Certificate[0]);
50+
Collection<? extends Certificate> certs;
51+
try {
52+
certs = factory.generateCertificates(new ByteArrayInputStream(pem));
53+
} catch (CertificateException e) {
54+
// JDK X509Factory throws "No certificate data found" for input with no
55+
// BEGIN CERTIFICATE block. Treat that as an empty chain rather than a parse error.
56+
if (e.getMessage() != null && e.getMessage().contains("No certificate data found")) {
57+
throw new IllegalStateException(
58+
"No certificates found in TLS certificate chain: " + source);
59+
}
60+
throw e;
61+
}
62+
Certificate[] chain = certs.toArray(new Certificate[0]);
63+
if (chain.length == 0) {
64+
throw new IllegalStateException(
65+
"No certificates found in TLS certificate chain: " + source);
66+
}
67+
return chain;
4968
} catch (GeneralSecurityException e) {
5069
throw new IllegalStateException("Failed to parse TLS certificate chain from " + source, e);
5170
}
@@ -92,6 +111,7 @@ private static PrivateKey decodePrivateKey(String pem, Path source) {
92111

93112
private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) {
94113
verifyKeyMatchesCert(key, chain[0]);
114+
requireMinimumStrength(key, chain[0]);
95115
try {
96116
KeyStore ks = KeyStore.getInstance("PKCS12");
97117
ks.load(null, null);
@@ -106,6 +126,29 @@ private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) {
106126
}
107127
}
108128

129+
private static void requireMinimumStrength(PrivateKey key, Certificate cert) {
130+
PublicKey publicKey = cert.getPublicKey();
131+
switch (publicKey) {
132+
case RSAPublicKey rsa -> {
133+
int bits = rsa.getModulus().bitLength();
134+
if (bits < 2048) {
135+
throw new IllegalStateException(
136+
"TLS RSA key below minimum strength: " + bits + " bits (require >= 2048)");
137+
}
138+
}
139+
case ECPublicKey ec -> {
140+
int bits = ec.getParams().getCurve().getField().getFieldSize();
141+
if (bits < 256) {
142+
throw new IllegalStateException(
143+
"TLS EC key below minimum strength: " + bits + " bits (require >= 256)");
144+
}
145+
}
146+
default ->
147+
throw new IllegalStateException(
148+
"Unsupported TLS public key algorithm: " + publicKey.getAlgorithm());
149+
}
150+
}
151+
109152
private static void verifyKeyMatchesCert(PrivateKey key, Certificate cert) {
110153
String algorithm =
111154
switch (key.getAlgorithm()) {

src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
import java.util.Optional;
2121
import javax.net.ssl.KeyManager;
2222
import javax.net.ssl.SSLContext;
23+
import javax.net.ssl.SSLSocket;
2324
import javax.net.ssl.TrustManagerFactory;
25+
import org.junit.jupiter.api.Test;
2426
import org.junit.jupiter.params.ParameterizedTest;
2527
import org.junit.jupiter.params.provider.CsvSource;
2628

@@ -70,6 +72,39 @@ void servesHttpsTraffic(String algo, String certPath, String keyPath) throws Exc
7072
}
7173
}
7274

75+
@Test
76+
void negotiatesTls13() throws Exception {
77+
Path cert = Path.of("src/test/resources/tls/rsa-cert.pem");
78+
Path key = Path.of("src/test/resources/tls/rsa-key.pem");
79+
80+
Spec spec;
81+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
82+
spec = Spec.fromJson(in);
83+
}
84+
85+
RequestHandler handler = req -> Response.ok(Map.of("hello", "world"));
86+
Map<String, RequestHandler> handlers = stubAllHandlers(spec, Map.of("get-data", handler));
87+
88+
try (OpenApiServer server =
89+
OpenApiServer.builder()
90+
.spec(spec)
91+
.handlers(handlers)
92+
.securityValidator("apiKeyAuth", (req, cred) -> Optional.empty())
93+
.securityValidator("bearerAuth", (req, cred) -> Optional.empty())
94+
.securityValidator("basicAuth", (req, cred) -> Optional.empty())
95+
.port(0)
96+
.https(cert, key)
97+
.build()) {
98+
99+
SSLContext clientCtx = trustStoreFor(cert);
100+
try (SSLSocket socket =
101+
(SSLSocket) clientCtx.getSocketFactory().createSocket("localhost", server.listenPort())) {
102+
socket.startHandshake();
103+
assertThat(socket.getSession().getProtocol()).isEqualTo("TLSv1.3");
104+
}
105+
}
106+
}
107+
73108
private static SSLContext trustStoreFor(Path certPath) throws Exception {
74109
byte[] bytes = Files.readAllBytes(certPath);
75110
Certificate cert =

src/test/java/com/retailsvc/http/internal/PemSslContextTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
55

6+
import java.nio.file.Files;
67
import java.nio.file.Path;
78
import javax.net.ssl.SSLContext;
89
import org.junit.jupiter.api.Test;
@@ -70,4 +71,24 @@ void rejectsMismatchedCertAndKey() {
7071
.isInstanceOf(IllegalStateException.class)
7172
.hasMessageContaining("do not match");
7273
}
74+
75+
@Test
76+
void rejectsEmptyCertificateChain() throws Exception {
77+
Path emptyCert = Files.createTempFile("empty-chain", ".pem");
78+
Files.writeString(emptyCert, "# no certificates here\n");
79+
try {
80+
assertThatThrownBy(() -> PemSslContext.load(emptyCert, RSA_KEY))
81+
.isInstanceOf(IllegalStateException.class)
82+
.hasMessageContaining("No certificates found in TLS certificate chain");
83+
} finally {
84+
Files.deleteIfExists(emptyCert);
85+
}
86+
}
87+
88+
@Test
89+
void acceptsEcKeyAtMinimumStrength() throws Exception {
90+
// P-256 (256 bits) is exactly at the floor — must pass.
91+
SSLContext ctx = PemSslContext.load(EC_CERT, EC_KEY);
92+
assertThat(ctx).isNotNull();
93+
}
7394
}

0 commit comments

Comments
 (0)