diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java new file mode 100644 index 00000000..3f47ed7e --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java @@ -0,0 +1,82 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class SslContextBuilder { + + public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx) throws Exception { + SSLContext sslContext = SSLContext.getInstance("TLS"); + + if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) { + sslContext.init(null, new TrustManager[]{new TrustAllManager()}, null); + return sslContext; + } + + List extraCerts = new ArrayList<>(); + List paths = new ArrayList<>(systemProps.trustedCaPemPaths()); + if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) { + paths.addAll(ctx.trustedCaPemPaths()); + } + for (String p : paths) { + extraCerts.addAll(loadPem(p)); + } + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + // Load JDK default trust roots + TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultTmf.init((KeyStore) null); + int i = 0; + for (TrustManager tm : defaultTmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509tm) { + for (X509Certificate cert : x509tm.getAcceptedIssuers()) { + ks.setCertificateEntry("default-" + (i++), cert); + } + } + } + // Add configured extras + for (X509Certificate cert : extraCerts) { + ks.setCertificateEntry("extra-" + (i++), cert); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } + + private Collection loadPem(String path) throws IOException, java.security.cert.CertificateException { + Path p = Path.of(path); + if (!Files.exists(p)) { + throw new IllegalArgumentException("CA file not found: " + path); + } + try (var in = Files.newInputStream(p)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + @SuppressWarnings("unchecked") + Collection certs = (Collection) cf.generateCertificates(in); + return certs; + } + } + + private static final class TrustAllManager implements X509TrustManager { + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java new file mode 100644 index 00000000..1bed80b2 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java @@ -0,0 +1,52 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SslContextBuilderTest { + private final OutboundHttpProperties systemProps = + new OutboundHttpProperties(false, List.of(), Duration.ofMillis(2000), Duration.ofMillis(5000), + null, null, null); + private final SslContextBuilder builder = new SslContextBuilder(); + + @Test + void systemDefaultUsesJdkTrustStore() throws Exception { + SSLContext ctx = builder.build(systemProps, OutboundHttpRequestContext.systemDefault()); + assertThat(ctx).isNotNull(); + assertThat(ctx.getProtocol()).isEqualTo("TLS"); + } + + @Test + void trustAllSkipsValidation() throws Exception { + SSLContext ctx = builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null)); + assertThat(ctx).isNotNull(); + } + + @Test + void trustPathsLoadsPemFile() throws Exception { + Path pem = Path.of("src/test/resources/test-ca.pem"); + assertThat(pem).exists(); + SSLContext ctx = builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of(pem.toString()), null, null)); + assertThat(ctx).isNotNull(); + } + + @Test + void trustPathsMissingFileThrows() { + assertThatThrownBy(() -> builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of("/no/such/file.pem"), null, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CA file not found"); + } +} diff --git a/cameleer-server-app/src/test/resources/test-ca.pem b/cameleer-server-app/src/test/resources/test-ca.pem new file mode 100644 index 00000000..37ced8f3 --- /dev/null +++ b/cameleer-server-app/src/test/resources/test-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUawEUnI7oAYTMsqYYJUBqGJhM8LMwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQY2FtZWxlZXItdGVzdC1jYTAeFw0yNjA0MTkxMzUzMTNa +Fw0zNjA0MTYxMzUzMTNaMBsxGTAXBgNVBAMMEGNhbWVsZWVyLXRlc3QtY2EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCalBl0hjPSXYZjFLImDT5awayX +nPmX7/TyduX2xSksLnGBfD1YcLYhIaOU3Bp6o+Wld8+nzNjwgMUcKdv1mWqDL8t0 +nlf0JGAntNEQWGwxFUGSLcmzgVf8/d1s0mpZ1/mS6RDzoQ8i8rNq5mYPXkWFAvgD +G1FQpaBs71VC7vVcEgnJ4kK/8cNcQ/nvaI+T/Tk+sciu57XuMoa8AoJV/Oz/hdkc +fIoYeUpFp36pYn71yFyZ1N+1xCTi1ruBmMekYWKNaNY6/edk2z3iTFgv4Dk4QS1O +6GEdgUi9RQIgQwyYltQK2z+wLA8e982JK5HllsLYU0AcY6fvg+JlhEEpCsdnAgMB +AAGjUzBRMB0GA1UdDgQWBBQYRTifnA5YLxiqjIYMDcZLgDrtUDAfBgNVHSMEGDAW +gBQYRTifnA5YLxiqjIYMDcZLgDrtUDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBpNfv6I9dsUflmslv6el5VvDOyZ3GWQIE/Zy0p+i0NYjeWMWtX +rFPw1mnfdhlyFMpNkP8ddyDaSoqnnFsC4pXqXibrey0NDYX7zujyVIaXixf6koU9 +2/vNIb4evnJyVT9CcophDrobkR4NwU9SrMO7KAecb/U6shrfgmjIanUnCsfGdwsP +f85DQDbOd9UhnMfMB43I8tjn2Po155npmwGK7J4hZpWTUj/rbNt3fmy2mx6rqdkp +p61nLY3ba61bnl7QQ7VW7nhaIC7t5sO6NFDdv06MG8Cr5kcvoJDPe93iNeVT8iLp +Rxs85FvIVYIt58jMvr+RSEfTD8fIvXl0uRDy +-----END CERTIFICATE-----