From 43483eaa9386ea02876d8bcf8fe7c74be4622ae6 Mon Sep 17 00:00:00 2001 From: Vincent Latombe Date: Tue, 8 Oct 2024 15:31:19 +0200 Subject: [PATCH] Have RealJenkinsRule generates a https certificate automatically --- pom.xml | 9 + .../jenkins/test/https/KeyStoreManager.java | 261 ++++++++++++++++++ .../test/https/SelfSignedCertificates.java | 167 +++++++++++ .../SelfSignedCertificatesException.java | 38 +++ .../jvnet/hudson/test/RealJenkinsRule.java | 118 ++++++-- 5 files changed, 577 insertions(+), 16 deletions(-) create mode 100644 src/main/java/jenkins/test/https/KeyStoreManager.java create mode 100644 src/main/java/jenkins/test/https/SelfSignedCertificates.java create mode 100644 src/main/java/jenkins/test/https/SelfSignedCertificatesException.java diff --git a/pom.xml b/pom.xml index 0134d06da..542ca1681 100644 --- a/pom.xml +++ b/pom.xml @@ -96,6 +96,11 @@ THE SOFTWARE. access-modifier-annotation 1.34 + + org.bouncycastle + bcpkix-jdk18on + 1.78.1 + @@ -212,6 +217,10 @@ THE SOFTWARE. jmh-generator-annprocess ${jmh.version} + + org.bouncycastle + bcpkix-jdk18on + org.jenkins-ci test-annotations diff --git a/src/main/java/jenkins/test/https/KeyStoreManager.java b/src/main/java/jenkins/test/https/KeyStoreManager.java new file mode 100644 index 000000000..364140860 --- /dev/null +++ b/src/main/java/jenkins/test/https/KeyStoreManager.java @@ -0,0 +1,261 @@ +/* + * The MIT License + * + * Copyright 2024 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.test.https; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.List; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Allows to manage a java keystore file more easily than base JDK. + */ +public class KeyStoreManager { + @NonNull + private final Path path; + @NonNull + private final URL url; + @NonNull + private final String password; + @NonNull + private final KeyStore keyStore; + @NonNull + private final String type; + + /** + * Creates a new instance using the default keystore type. + * @param path path of the keystore file. If it exists, it will be loaded automatically. + * @param password password for the keystore file. + */ + public KeyStoreManager(@NonNull Path path, @NonNull String password) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + this(path, password, KeyStore.getDefaultType()); + } + + /** + * Creates a new instance using the specified keystore type. + * @param path path of the keystore file. If it exists, it will be loaded automatically. + * @param password password for the keystore file. + * @param type type of the keystore file. + */ + public KeyStoreManager(@NonNull Path path, @NonNull String password, @NonNull String type) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + this.path = path; + this.url = path.toUri().toURL(); + this.password = password; + this.type = type; + var tmpKeyStore = KeyStore.getInstance(type); + if (Files.exists(path)) { + try (var is = Files.newInputStream(path)) { + tmpKeyStore.load(is, password.toCharArray()); + } + } else { + tmpKeyStore.load(null); + } + this.keyStore = tmpKeyStore; + } + + @NonNull + private static X509TrustManager getDefaultX509CertificateTrustManager(TrustManagerFactory trustManagerFactory) { + return Arrays.stream(trustManagerFactory.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Could not load default trust manager")); + } + + /** + * @return the password for the managed keystore + */ + public String getPassword() { + return password; + } + + /** + * @return the path where the managed keystore is persisted to. + * Make sure {@link #save()} has been called before using the path. + */ + public Path getPath() { + return path; + } + + /** + * @return the type of the managed keystore. + */ + public String getType() { + return type; + } + + /** + * @return returns the URL representation of the keystore file. + *

+ * Make sure {@link #save()} has been called before using the path. + */ + public URL getURL() { + return url; + } + + /** + * Persists the current keystore to disk. + */ + public void save() throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException { + try (var os = Files.newOutputStream(path)) { + keyStore.store(os, password.toCharArray()); + } + } + + /** + * Build a custom SSL context that trusts the default certificates as well as those in the current keystore. + */ + @NonNull + public SSLContext buildClientSSLContext() + throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException, + KeyManagementException { + X509TrustManager result; + try (var myKeysInputStream = Files.newInputStream(path)) { + var myTrustStore = KeyStore.getInstance(type); + myTrustStore.load(myKeysInputStream, password.toCharArray()); + var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(myTrustStore); + result = getDefaultX509CertificateTrustManager(trustManagerFactory); + } + var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + var wrapper = new MergedTrustManager(getDefaultX509CertificateTrustManager(trustManagerFactory), result); + var context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] {wrapper}, null); + return context; + } + + /** + * Build server context for server usage. + * @return a SSLContext instance configured with the key store. + */ + public SSLContext buildServerSSLContext() { + final KeyManager[] keyManagers; + try { + var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, password.toCharArray()); + keyManagers = keyManagerFactory.getKeyManagers(); + } catch (NoSuchAlgorithmException | UnrecoverableKeyException | KeyStoreException e) { + throw new RuntimeException("Unable to initialise KeyManager[]", e); + } + + final TrustManager[] trustManagers; + try { + var trustManagerFactory = TrustManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + trustManagers = trustManagerFactory.getTrustManagers(); + } catch (NoSuchAlgorithmException | KeyStoreException e) { + throw new RuntimeException("Unable to initialise TrustManager[]", e); + } + + try { + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, trustManagers, null); + return sslContext; + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException("Unable to create and initialise the SSLContext", e); + } + } + + /** + * @see KeyStore#setCertificateEntry(String, Certificate) + */ + public void setCertificateEntry(String alias, X509Certificate certificate) throws KeyStoreException { + keyStore.setCertificateEntry(alias, certificate); + } + + /** + * @see KeyStore#setKeyEntry(String, byte[], Certificate[]) + */ + public void setKeyEntry(String host, PrivateKey privateKey, Certificate[] certificates) throws KeyStoreException { + keyStore.setKeyEntry(host, privateKey, password.toCharArray(), certificates); + } + + private static class MergedTrustManager implements X509TrustManager { + private final X509TrustManager defaultTrustManager; + private final List trustManagers; + + public MergedTrustManager(X509TrustManager defaultTrustManager, X509TrustManager... trustManagers) { + this.defaultTrustManager = defaultTrustManager; + this.trustManagers = List.of(trustManagers); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return trustManagers.stream() + .map(X509TrustManager::getAcceptedIssuers) + .flatMap(Arrays::stream) + .toArray(X509Certificate[]::new); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + CertificateException exceptionResult = null; + for (var trustManager : trustManagers) { + try { + trustManager.checkServerTrusted(chain, authType); + return; + } catch (CertificateException e) { + if (exceptionResult == null) { + exceptionResult = e; + } else { + exceptionResult.addSuppressed(e); + } + } + } + if (exceptionResult != null) { + throw exceptionResult; + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + defaultTrustManager.checkClientTrusted(trustManagers.stream() + .map(X509TrustManager::getAcceptedIssuers) + .flatMap(Arrays::stream) + .toArray(X509Certificate[]::new), authType); + } + } +} diff --git a/src/main/java/jenkins/test/https/SelfSignedCertificates.java b/src/main/java/jenkins/test/https/SelfSignedCertificates.java new file mode 100644 index 000000000..3999ca6f4 --- /dev/null +++ b/src/main/java/jenkins/test/https/SelfSignedCertificates.java @@ -0,0 +1,167 @@ +/* + * The MIT License + * + * Copyright 2024 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.test.https; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * A utility class to generate self-signed certificates. + *

+ * Then you can generate user certificates for a given DNS name signed by the intermediate CA. + * + * @see #createRootCAs() + * @see #createUserCert(String, CertificateKeyPair) + */ +public record SelfSignedCertificates(CertificateKeyPair root, CertificateKeyPair intermediate) { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String CERTIFICATE_ALGORITHM = "RSA"; + private static final int CERTIFICATE_BITS = 2048; + private static final String ROOT_DN = "CN=Root"; + + static { + // adds the Bouncy castle provider to java security + Security.addProvider(new BouncyCastleProvider()); + } + + @NonNull + public static SelfSignedCertificates createRootCAs() { + try { + // create root CA self-signed. + var rootKeyPair = generateKeyPair(); + var rootBuilder = new JcaX509v3CertificateBuilder( + new X500Name(ROOT_DN), + generateSerialNumber(), + Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()), + Date.from(LocalDate.now() + .plusDays(4) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant()), + new X500Name(ROOT_DN), + SubjectPublicKeyInfo.getInstance(rootKeyPair.getPublic().getEncoded())); + rootBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign)); + rootBuilder.addExtension(Extension.basicConstraints, false, new BasicConstraints(true)); + var root = new CertificateKeyPair(rootKeyPair, new JcaX509CertificateConverter().getCertificate(rootBuilder.build(newContentSigner(rootKeyPair)))); + + // create Intermediate CA cert signed by Root CA + var intermediateKeyPair = generateKeyPair(); + var intermediateBuilder = new JcaX509v3CertificateBuilder( + root.certificate(), + generateSerialNumber(), + Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()), + Date.from(LocalDate.now() + .plusDays(2) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant()), + new X500Name("CN=Intermediate"), + intermediateKeyPair.getPublic()); + intermediateBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign)); + intermediateBuilder.addExtension(Extension.basicConstraints, false, new BasicConstraints(true)); + var intermediate = new CertificateKeyPair(intermediateKeyPair, new JcaX509CertificateConverter().getCertificate(intermediateBuilder.build(newContentSigner(root.keyPair())))); + return new SelfSignedCertificates(root, intermediate); + } catch (OperatorCreationException | CertificateException | CertIOException | NoSuchAlgorithmException e) { + throw new SelfSignedCertificatesException(e); + } + } + + private static ContentSigner newContentSigner(KeyPair keyPair) throws OperatorCreationException { + return new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(keyPair.getPrivate()); + } + + @NonNull + static BigInteger generateSerialNumber() { + return BigInteger.valueOf(RANDOM.nextInt()); + } + + /** + * Generate a user certificate signed by the intermediate CA. + * @param dnsName The DNS name to use in the certificate. + * @param issuer The intermediate CA to sign the certificate. + * @return The user certificate and key pair. + */ + public static CertificateKeyPair createUserCert(String dnsName, CertificateKeyPair issuer) { + try { + var keyPair = generateKeyPair(); + // create end user cert signed by Intermediate CA + var builder = new JcaX509v3CertificateBuilder( + issuer.certificate(), + generateSerialNumber(), + Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()), + Date.from(LocalDate.now() + .plusDays(1) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant()), + new X500Name("CN=endUserCert"), + keyPair.getPublic()); + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); + builder.addExtension(Extension.basicConstraints, false, new BasicConstraints(false)); + var altNames = List.of(new GeneralName(GeneralName.dNSName, dnsName)); + var subjectAltNames = GeneralNames.getInstance(new DERSequence(altNames.toArray(new GeneralName[0]))); + builder.addExtension(Extension.subjectAlternativeName, false, subjectAltNames); + return new CertificateKeyPair( + keyPair, + new JcaX509CertificateConverter().getCertificate(builder.build(newContentSigner(issuer.keyPair())))); + } catch (OperatorCreationException | CertificateException | CertIOException | NoSuchAlgorithmException e) { + throw new SelfSignedCertificatesException(e); + } + } + + private static KeyPair generateKeyPair() throws NoSuchAlgorithmException { + var keyPairGenerator = KeyPairGenerator.getInstance(CERTIFICATE_ALGORITHM); + keyPairGenerator.initialize(CERTIFICATE_BITS, RANDOM); + return keyPairGenerator.generateKeyPair(); + } + + public record CertificateKeyPair(KeyPair keyPair, X509Certificate certificate) {} +} diff --git a/src/main/java/jenkins/test/https/SelfSignedCertificatesException.java b/src/main/java/jenkins/test/https/SelfSignedCertificatesException.java new file mode 100644 index 000000000..f05858941 --- /dev/null +++ b/src/main/java/jenkins/test/https/SelfSignedCertificatesException.java @@ -0,0 +1,38 @@ +/* + * The MIT License + * + * Copyright 2024 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.test.https; + +/** + * Raised when something went wrong when generating self-signed certificates. + */ +public class SelfSignedCertificatesException extends RuntimeException { + public SelfSignedCertificatesException(Throwable cause) { + super(cause); + } + + public SelfSignedCertificatesException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java index d6a485849..b7fbb9c06 100644 --- a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java +++ b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java @@ -26,6 +26,7 @@ import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.ExtensionList; import hudson.model.UnprotectedRootAction; @@ -61,9 +62,16 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileAttribute; +import java.security.KeyManagementException; import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; @@ -93,7 +101,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; @@ -112,6 +122,8 @@ import org.junit.rules.Timeout; import org.junit.runner.Description; import org.junit.runners.model.Statement; +import jenkins.test.https.KeyStoreManager; +import jenkins.test.https.SelfSignedCertificates; import org.jvnet.hudson.test.recipes.LocalData; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; @@ -218,11 +230,10 @@ public final class RealJenkinsRule implements TestRule { private static final Pattern SNAPSHOT_INDEX_JELLY = Pattern.compile("(file:/.+/target)/classes/index.jelly"); private final PrefixedOutputStream.Builder prefixedOutputStreamBuilder = PrefixedOutputStream.builder(); - @CheckForNull + private boolean https; + private KeyStoreManager keyStoreManager; private SSLSocketFactory sslSocketFactory; - private File keyStoreFile; - private String keyStorePassword; - private URL keyStoreUrl; + private X509Certificate rootCA; public RealJenkinsRule() { home = new AtomicReference<>(); @@ -319,8 +330,13 @@ public RealJenkinsRule withTimeout(int timeout) { * might accidentally be shared across otherwise distinct services. *

Calling this method does not change the fact that Jenkins will be configured to listen only on localhost for security reasons * (so others in the same network cannot access your system under test, especially if it lacks authentication). + *

+ * When using HTTPS, use @{link #https(String)} instead. */ public RealJenkinsRule withHost(String host) { + if (https) { + throw new IllegalStateException("Don't call this method when using HTTPS"); + } this.host = host; return this; } @@ -740,17 +756,87 @@ public URL getUrl() throws MalformedURLException { if (port == 0) { throw new IllegalStateException("This method must be called after calling #startJenkins."); } - return new URL(sslSocketFactory != null ? "https" : "http", host, port, "/jenkins/"); + return new URL(https ? "https" : "http", host, port, "/jenkins/"); + } + + /** + * Sets up HTTPS for the current instance, and disables plain HTTP. + * This generates a self-signed certificate for localhost. The corresponding root CA that needs to be trusted by HTTP client can be obtained using {@link #getRootCA()}. + * + * @return the current instance + * @see #createWebClient() + */ + public RealJenkinsRule https() { + return https("localhost"); } - public RealJenkinsRule https(File keyStoreFile, String keyStorePassword, SSLSocketFactory sslSocketFactory) throws MalformedURLException { - this.sslSocketFactory = sslSocketFactory; - this.keyStoreFile = keyStoreFile; - this.keyStoreUrl = keyStoreFile.toURI().toURL(); - this.keyStorePassword = keyStorePassword; + + /** + * Sets up HTTPS for the current instance, and disables plain HTTP. + * This generates a self-signed certificate for the given host name. The corresponding root CA that needs to be trusted by HTTP client can be obtained using {@link #getRootCA()}. + *

+ * You don't need to call {@link #withHost(String)} when calling this method. + * + * @param host the host name to use in the certificate + * @return the current instance + * @see #createWebClient() + * @see #withHost(String) + */ + public RealJenkinsRule https(@NonNull String host) { + this.host = host; + this.https = true; + try { + var keyStoreManager = new KeyStoreManager(createTempDirectory("keystore").resolve("keyStore.p12"), token); + var rootCAs = SelfSignedCertificates.createRootCAs(); + var userCert = SelfSignedCertificates.createUserCert(host, rootCAs.intermediate()); + keyStoreManager.setCertificateEntry(host, rootCAs.root().certificate()); + keyStoreManager.setKeyEntry(host, userCert.keyPair().getPrivate(), new Certificate[]{ + userCert.certificate(), + rootCAs.intermediate().certificate(), + rootCAs.root().certificate() + }); + keyStoreManager.save(); + this.rootCA = rootCAs.root().certificate(); + this.keyStoreManager = keyStoreManager; + this.sslSocketFactory = keyStoreManager.buildClientSSLContext().getSocketFactory(); + } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | KeyManagementException | + IOException e) { + throw new RuntimeException(e); + } return this; } + /** + * @return the current autogenerated root CA or null if {@link #https(String)} has not been called. + */ + @Nullable + public X509Certificate getRootCA() { + return rootCA; + } + + /** + * Builds a {@link SSLContext} trusting the current instance. + */ + @NonNull + public SSLContext buildSSLContext() throws NoSuchAlgorithmException { + if (rootCA != null) { + try { + var myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + myTrustStore.load(null, null); + myTrustStore.setCertificateEntry(getName() != null ? getName() : UUID.randomUUID().toString(), rootCA); + var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(myTrustStore); + var context = SSLContext.getInstance("TLS"); + context.init(null, trustManagerFactory.getTrustManagers(), null); + return context; + } catch (CertificateException | KeyManagementException | IOException | KeyStoreException e) { + throw new RuntimeException(e); + } + } else { + return SSLContext.getDefault(); + } + } + private URL endpoint(String method) throws MalformedURLException { return new URL(getUrl(), "RealJenkinsRule/" + method + "?token=" + token); } @@ -792,12 +878,12 @@ private static File findJenkinsWar() throws Exception { } /** - * Create a client configured to trust the self-signed certificate used by Jenkins. + * Create a client configured to trust the self-signed certificate used by this instance. */ public WebClient createWebClient() { var wc = new WebClient(); - if (keyStoreUrl != null && keyStorePassword != null) { - wc.getOptions().setSSLTrustStore(keyStoreUrl, keyStorePassword, KeyStore.getDefaultType()); + if (keyStoreManager != null) { + wc.getOptions().setSSLTrustStore(keyStoreManager.getURL(), keyStoreManager.getPassword(), keyStoreManager.getType()); } return wc; } @@ -855,8 +941,8 @@ public void startJenkins() throws Throwable { "--httpListenAddress=" + httpListenAddress, "--prefix=/jenkins")); argv.addAll(getPortOptions()); - if (keyStoreFile != null && keyStorePassword != null) { - argv.addAll(List.of("--httpsKeyStore=" + keyStoreFile.getAbsolutePath(), "--httpsKeyStorePassword=" + keyStorePassword)); + if (https) { + argv.addAll(List.of("--httpsKeyStore=" + keyStoreManager.getPath().toAbsolutePath(), "--httpsKeyStorePassword=" + token)); } argv.addAll(jenkinsOptions); Map env = new TreeMap<>(); @@ -927,7 +1013,7 @@ public void startJenkins() throws Throwable { private Collection getPortOptions() { // Initially port=0. On subsequent runs, this is set to the port allocated randomly on the first run. - if (sslSocketFactory != null) { + if (https) { return List.of("--httpPort=-1", "--httpsPort=" + port); } else { return List.of("--httpPort=" + port);