diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index d5418bf2e..d3b6a30b7 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,8 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/1552[#1552]: Add Commandlet to fix TLS issue + The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/43?closed=1[milestone 2026.04.002]. == 2026.04.001 diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index c44f5b740..4946f6263 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -109,6 +109,7 @@ public CommandletManagerImpl(IdeContext context) { add(new InstallPluginCommandlet(context)); add(new UninstallPluginCommandlet(context)); add(new UpgradeCommandlet(context)); + add(new TruststoreCommandlet(context)); add(new Gh(context)); add(new Helm(context)); add(new Java(context)); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java new file mode 100644 index 000000000..fded542d5 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java @@ -0,0 +1,203 @@ +package com.devonfw.tools.ide.commandlet; + +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.property.StringProperty; +import com.devonfw.tools.ide.util.TruststoreUtil; + +/** + * {@link Commandlet} to fix the TLS problem for VPN users. + */ +public class TruststoreCommandlet extends Commandlet { + + private static final Logger LOG = LoggerFactory.getLogger(TruststoreCommandlet.class); + + private static final String IDE_OPTIONS = "IDE_OPTIONS"; + + private static final String TRUSTSTORE_OPTION_PREFIX = "-Djavax.net.ssl.trustStore="; + + private static final String TRUSTSTORE_PASSWORD_OPTION_PREFIX = "-Djavax.net.ssl.trustStorePassword="; + + private final StringProperty url; + + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public TruststoreCommandlet(IdeContext context) { + super(context); + addKeyword(getName()); + this.url = add(new StringProperty("", false, "url")); + } + + @Override + public String getName() { + return "fix-vpn-tls-problem"; + } + + @Override + public boolean isIdeHomeRequired() { + return false; + } + + /** + * This commandlet tries to fix TLS problems for VPN users by capturing the untrusted certificate from the target endpoint and adding it to a custom + * truststore. It also configures IDE_OPTIONS to use the custom truststore by default. The commandlet is idempotent and will not make changes if the endpoint + * is already reachable or if the certificate is already trusted. + *

+ * The flow is as follows: + *

+ */ + @Override + protected void doRun() { + + String endpointInput = this.url.getValueAsString(); + boolean defaultUrlUsed = false; + + if (endpointInput == null || endpointInput.isBlank()) { + endpointInput = "https://www.github.com"; + defaultUrlUsed = true; + } + + TruststoreUtil.TlsEndpoint endpoint; + try { + endpoint = TruststoreUtil.parseTlsEndpoint(endpointInput); + } catch (IllegalArgumentException e) { + throw new CliException("Invalid target URL/host '" + endpointInput + "': " + e.getMessage(), e); + } + + String host = endpoint.host(); + int port = endpoint.port(); + Path customTruststorePath = this.context.getUserHomeIde().resolve("truststore").resolve("truststore.p12"); + + if (TruststoreUtil.isTruststorePresent(customTruststorePath) && TruststoreUtil.isReachable(host, port, customTruststorePath)) { + IdeLogLevel.SUCCESS.log(LOG, "TLS handshake succeeded with existing custom truststore at {}.", customTruststorePath); + configureIdeOptions(customTruststorePath); + return; + } + + if (TruststoreUtil.isReachable(host, port)) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully connected to {}:{} without certificate changes.", host, port); + LOG.info("No truststore update is required for the given address."); + if (defaultUrlUsed) { + LOG.info( + "If the issue still occurs try to call the command again and add the url that is causing the problem to the command: \n ide fix-vpn-tls-problem "); + } + + return; + } + + LOG.info("The given address {}:{} is not reachable/valid without certificate changes. Continuing with certificate capture.", host, port); + + X509Certificate certificate; + try { + certificate = TruststoreUtil.fetchServerCertificate(host, port); + } catch (Exception e) { + LOG.error("Failed to capture certificate from {}:{}.", host, port, e); + IdeLogLevel.INTERACTION.log(LOG, + "Please check proxy/VPN and retry. You can also follow: https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + return; + } + + LOG.info("Captured untrusted certificate:"); + LOG.info(TruststoreUtil.describeCertificate(certificate)); + + boolean addToTruststore = this.context.question("Do you want to add this certificate to the custom truststore at {}?", customTruststorePath); + + if (!addToTruststore) { + LOG.info("Skipped truststore update by user choice."); + return; + } + + try { + TruststoreUtil.createOrUpdateTruststore(customTruststorePath, certificate, "custom"); + IdeLogLevel.SUCCESS.log(LOG, "Custom truststore updated at {}", customTruststorePath); + } catch (Exception e) { + LOG.error("Failed to create or update custom truststore at {}", customTruststorePath, e); + return; + } + + configureIdeOptions(customTruststorePath); + + if (TruststoreUtil.isReachable(host, port, customTruststorePath)) { + IdeLogLevel.SUCCESS.log(LOG, "TLS handshake succeeded with custom truststore."); + } else { + LOG.warn("TLS handshake still fails even with custom truststore."); + } + } + + private void configureIdeOptions(Path customTruststorePath) { + String truststorePath = customTruststorePath.toAbsolutePath().toString(); + String truststoreOption = TRUSTSTORE_OPTION_PREFIX + truststorePath; + String truststorePasswordOption = TRUSTSTORE_PASSWORD_OPTION_PREFIX + Arrays.toString(TruststoreUtil.CUSTOM_TRUSTSTORE_PASSWORD); + + EnvironmentVariables confVariables = this.context.getVariables().getByType(EnvironmentVariablesType.USER); + + if (confVariables == null) { + IdeLogLevel.INTERACTION.log(LOG, "Please configure IDE_OPTIONS manually: {} {}", truststoreOption, truststorePasswordOption); + return; + } + + String options = confVariables.getFlat(IDE_OPTIONS); + options = removeOptionWithPrefix(options, TRUSTSTORE_OPTION_PREFIX); + options = removeOptionWithPrefix(options, TRUSTSTORE_PASSWORD_OPTION_PREFIX); + options = appendOption(options, truststoreOption); + options = appendOption(options, truststorePasswordOption); + + try { + confVariables.set(IDE_OPTIONS, options, true); + confVariables.save(); + // Apply directly for the current process as well. + System.setProperty("javax.net.ssl.trustStore", truststorePath); + System.setProperty("javax.net.ssl.trustStorePassword", Arrays.toString(TruststoreUtil.CUSTOM_TRUSTSTORE_PASSWORD)); + IdeLogLevel.SUCCESS.log(LOG, "IDE_OPTIONS configured to use custom truststore by default."); + } catch (UnsupportedOperationException e) { + IdeLogLevel.INTERACTION.log(LOG, "Please configure IDE_OPTIONS manually: {} {}", truststoreOption, truststorePasswordOption); + } + } + + private static String removeOptionWithPrefix(String options, String prefix) { + if ((options == null) || options.isBlank()) { + return ""; + } + StringBuilder result = new StringBuilder(); + String[] tokens = options.trim().split("\\s+"); + for (String token : tokens) { + if (!token.startsWith(prefix)) { + if (!result.isEmpty()) { + result.append(' '); + } + result.append(token); + } + } + return result.toString(); + } + + private static String appendOption(String options, String option) { + if ((options == null) || options.isBlank()) { + return option; + } + return options + " " + option; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java b/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java index 77c13fb94..405d24094 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java @@ -3,8 +3,8 @@ import java.io.IOException; import java.net.URL; import java.net.URLConnection; +import java.util.Locale; import java.util.concurrent.Callable; -import javax.net.ssl.SSLException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +29,8 @@ public class NetworkStatusImpl implements NetworkStatus { protected final CachedValue onlineCheck; + private static final String ERROR_TEXT_PKIX = "pkix path building failed"; + /** * @param ideContext the {@link AbstractIdeContext}. */ @@ -113,10 +115,8 @@ public void logStatusMessage() { LOG.error(message); LOG.error(error.toString()); } - if (error instanceof SSLException) { - LOG.warn( - "You are having TLS issues. We guess you are forced to use a VPN tool breaking end-to-end encryption causing this effect. As a workaround you can create and configure a truststore as described here:"); - IdeLogLevel.INTERACTION.log(LOG, "https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + if (isTlsTrustIssue(error)) { + logTruststoreFixHint(); } else { IdeLogLevel.INTERACTION.log(LOG, "Please check potential proxy settings, ensure you are properly connected to the internet and retry this operation."); } @@ -133,9 +133,40 @@ public T invokeNetworkTask(Callable callable, String uri) { return callable.call(); } catch (IOException e) { this.onlineCheck.set(e); + if (isTlsTrustIssue(e)) { + logTruststoreFixHint(); + } throw new IllegalStateException("Network error whilst communicating to " + uri, e); } catch (Exception e) { throw new IllegalStateException("Unexpected checked exception whilst communicating to " + uri, e); } } + + private void logTruststoreFixHint() { + + LOG.warn( + "You are having TLS trust issues (PKIX/certificate-path/SSL handshake). As a workaround you can create and configure a truststore via the following command (replace with the failing endpoint):\nide fix-vpn-tls-problem "); + IdeLogLevel.INTERACTION.log(LOG, "https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + } + + boolean isTlsTrustIssue(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + String message = current.getMessage(); + if (containsTlsTrustIndicator(message)) { + return true; + } + current = current.getCause(); + } + return false; + } + + boolean containsTlsTrustIndicator(String text) { + if ((text == null) || text.isBlank()) { + return false; + } + String normalized = text.toLowerCase(Locale.ROOT); + return normalized.contains(ERROR_TEXT_PKIX); + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java b/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java new file mode 100644 index 000000000..13b530ef3 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java @@ -0,0 +1,446 @@ +package com.devonfw.tools.ide.util; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Utility methods for truststore handling and TLS certificate capture. + */ +public final class TruststoreUtil { + + /** + * Parsed TLS endpoint with host and port. + * + * @param host the server host. + * @param port the server port. + */ + public record TlsEndpoint(String host, int port) { + + } + + private static final String TRUSTSTORE_PASSWORD = "changeit"; + + /** + * Default password for the JRE cacerts truststore + */ + public static final char[] DEFAULT_CACERTS_PASSWORD = TRUSTSTORE_PASSWORD.toCharArray(); + + /** + * Password for the custom truststore + */ + public static final char[] CUSTOM_TRUSTSTORE_PASSWORD = TRUSTSTORE_PASSWORD.toCharArray(); + + /** + * Default prefix for aliases of certificates added to the truststore. + */ + private static final String DEFAULT_ALIAS_PREFIX = "custom"; + + private static final int DEFAULT_TIMEOUT_MILLIS = 10_000; + + private static final String TLS_PROTOCOL = "TLS"; + + private TruststoreUtil() { + // utility class + } + + /** + * Checks if a truststore file exists at the specified path. + * + * @param path the path to the truststore file. + * @return {@code true} if a truststore file exists at the specified path, {@code false} otherwise. + */ + public static boolean isTruststorePresent(Path path) { + return (path != null) && Files.exists(path); + } + + /** + * Loads the default Java truststore from the JRE cacerts file. + * + * @return the default Java truststore loaded from the JRE cacerts file. + * @throws Exception if an error occurs while loading the default truststore. + */ + public static KeyStore getDefaultTruststore() throws Exception { + String javaHome = System.getProperty("java.home"); + Path cacertsPath = Path.of(javaHome, "lib", "security", "cacerts"); + if (!Files.exists(cacertsPath)) { + throw new IllegalStateException("Default cacerts not found: " + cacertsPath); + } + + KeyStore cacerts = KeyStore.getInstance(KeyStore.getDefaultType()); + try (InputStream in = Files.newInputStream(cacertsPath)) { + cacerts.load(in, DEFAULT_CACERTS_PASSWORD); + } + return cacerts; + } + + /** + * Copies all certificate entries from the source truststore to the target truststore. Key entries are not copied, but if a key entry is encountered, its + * first certificate in the chain is copied as a certificate entry. + * + * @param source the source truststore to copy from. + * @param target the target truststore to copy to. + * @throws Exception if an error occurs while copying the truststore. + */ + public static void copyTruststore(KeyStore source, KeyStore target) throws Exception { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(target, "target"); + + Enumeration aliases = source.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (source.isCertificateEntry(alias)) { + Certificate cert = source.getCertificate(alias); + if (cert != null) { + target.setCertificateEntry(alias, cert); + } + } else if (source.isKeyEntry(alias)) { + Certificate[] chain = source.getCertificateChain(alias); + if ((chain != null) && (chain.length > 0)) { + target.setCertificateEntry(alias, chain[0]); + } + } + } + } + + /** + * Creates a new truststore at the specified path or updates an existing one by adding the given certificate if it is not already present. If the truststore + * does not + * + * @param customTruststorePath the path to the custom truststore file to create or update. + * @param certificate the certificate to add to the truststore if not already present. + * @param aliasPrefix the prefix to use for the alias of the new certificate (e.g. "custom"). If {@code null} or blank, a default prefix is used. + * @throws Exception if an error occurs while creating or updating the truststore. + */ + public static void createOrUpdateTruststore(Path customTruststorePath, X509Certificate certificate, String aliasPrefix) throws Exception { + Objects.requireNonNull(customTruststorePath, "customTruststorePath"); + Objects.requireNonNull(certificate, "certificate"); + + if ((aliasPrefix == null) || aliasPrefix.isBlank()) { + aliasPrefix = DEFAULT_ALIAS_PREFIX; + } + + Path parent = customTruststorePath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + KeyStore customStore = KeyStore.getInstance("PKCS12"); + if (isTruststorePresent(customTruststorePath)) { + try (InputStream in = Files.newInputStream(customTruststorePath)) { + customStore.load(in, CUSTOM_TRUSTSTORE_PASSWORD); + } + } else { + customStore.load(null, CUSTOM_TRUSTSTORE_PASSWORD); + copyTruststore(getDefaultTruststore(), customStore); + } + + if (!containsCertificate(customStore, certificate)) { + String alias = makeUniqueAlias(customStore, aliasPrefix); + addCertificate(customStore, alias, certificate); + } + + try (OutputStream out = Files.newOutputStream(customTruststorePath)) { + customStore.store(out, CUSTOM_TRUSTSTORE_PASSWORD); + } + } + + /** + * Adds the given certificate to the truststore under the specified alias. If the alias already exists, it will be overwritten. + * + * @param truststore the truststore to add the certificate to. + * @param alias the alias under which to add the certificate. + * @param certificate the certificate to add to the truststore. + * @throws Exception if an error occurs while adding the certificate to the truststore. + */ + public static void addCertificate(KeyStore truststore, String alias, X509Certificate certificate) throws Exception { + Objects.requireNonNull(truststore, "truststore"); + Objects.requireNonNull(alias, "alias"); + Objects.requireNonNull(certificate, "certificate"); + truststore.setCertificateEntry(alias, certificate); + } + + /** + * Parses a user input to a TLS endpoint supporting forms like {@code host}, {@code host:port}, and {@code https://host[:port]/path}. + * + * @param input the user input. + * @return the parsed {@link TlsEndpoint}. + */ + public static TlsEndpoint parseTlsEndpoint(String input) { + if ((input == null) || input.isBlank()) { + throw new IllegalArgumentException("URL/host must not be empty."); + } + String candidate = input.trim(); + + if (candidate.startsWith("http://")) { + throw new IllegalArgumentException("Only HTTPS URLs are supported: " + input); + } + + if (candidate.startsWith("https://")) { + return parseEndpointFromUri(input, URI.create(candidate)); + } + + if (candidate.contains("://")) { + URI uri = URI.create(candidate); + String scheme = uri.getScheme(); + if ((scheme == null) || !"https".equals(scheme.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Only HTTPS URLs are supported: " + input); + } + return parseEndpointFromUri(input, uri); + } + + int separatorIndex = candidate.lastIndexOf(':'); + if (separatorIndex > 0 && separatorIndex < (candidate.length() - 1) && candidate.indexOf(':') == separatorIndex) { + String host = candidate.substring(0, separatorIndex).trim(); + String portPart = candidate.substring(separatorIndex + 1).trim(); + int port; + try { + port = Integer.parseInt(portPart); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port in input: " + input, e); + } + validateEndpoint(host, port, input); + return new TlsEndpoint(host, port); + } + + validateEndpoint(candidate, 443, input); + return new TlsEndpoint(candidate, 443); + } + + private static TlsEndpoint parseEndpointFromUri(String input, URI uri) { + String host = uri.getHost(); + int port = (uri.getPort() > 0) ? uri.getPort() : 443; + validateEndpoint(host, port, input); + return new TlsEndpoint(host, port); + } + + private static void validateEndpoint(String host, int port, String input) { + if ((host == null) || host.isBlank()) { + throw new IllegalArgumentException("Missing host in input: " + input); + } + if ((port < 1) || (port > 65535)) { + throw new IllegalArgumentException("Port out of range in input: " + input); + } + } + + /** + * Checks if a TLS endpoint can be reached and validated with the current default trust configuration. + * + * @param host the server host to connect to. + * @param port the server port to connect to. + * @return {@code true} if TLS handshake succeeds without truststore changes, {@code false} otherwise. + */ + public static boolean isReachable(String host, int port) { + validateEndpoint(host, port, host + ":" + port); + try { + SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); + sslContext.init(null, null, new SecureRandom()); + SSLSocketFactory factory = sslContext.getSocketFactory(); + + try (SSLSocket socket = connectTlsSocket(factory, host, port)) { + socket.startHandshake(); + } + return true; + } catch (Exception e) { + return false; + } + + } + + /** + * Checks if a TLS endpoint can be reached and validated using the provided custom truststore. + * + * @param host the server host to connect to. + * @param port the server port to connect to. + * @param truststorePath the path to the custom truststore to use. + * @return {@code true} if TLS handshake succeeds with the custom truststore, {@code false} otherwise. + */ + public static boolean isReachable(String host, int port, Path truststorePath) { + validateEndpoint(host, port, host + ":" + port); + Objects.requireNonNull(truststorePath, "truststorePath"); + try { + verifyConnectionWithTruststore(host, port, truststorePath); + return true; + } catch (Exception e) { + return false; + } + } + + private static SSLSocket connectTlsSocket(SSLSocketFactory factory, String host, int port) throws Exception { + SSLSocket socket = (SSLSocket) factory.createSocket(); + try { + socket.connect(new InetSocketAddress(host, port), DEFAULT_TIMEOUT_MILLIS); + socket.setSoTimeout(DEFAULT_TIMEOUT_MILLIS); + return socket; + } catch (Exception e) { + try { + socket.close(); + } catch (Exception ignored) { + // ignore close failures on unsuccessful connect + } + throw e; + } + } + + /** + * Fetches the server certificate from the specified host and port by performing a TLS handshake and capturing the certificate chain using a custom trust + * manager. + * + * @param host the server host to connect to. + * @param port the server port to connect to. + * @return the server certificate captured from the TLS handshake. + * @throws Exception if an error occurs while fetching the server certificate, e.g. due to connection issues or if the server does not provide a + * certificate chain. + */ + public static X509Certificate fetchServerCertificate(String host, int port) throws Exception { + Objects.requireNonNull(host, "host"); + if (host.isBlank()) { + throw new IllegalArgumentException("host must not be blank"); + } + if ((port < 1) || (port > 65535)) { + throw new IllegalArgumentException("port must be between 1 and 65535"); + } + + SavingTrustManager savingTrustManager = new SavingTrustManager(); + + SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); + sslContext.init(null, new TrustManager[] { savingTrustManager }, new SecureRandom()); + + SSLSocketFactory factory = sslContext.getSocketFactory(); + try (SSLSocket socket = connectTlsSocket(factory, host, port)) { + socket.startHandshake(); + } catch (SSLException e) { + // expected: trust manager aborts after capturing the chain + } + + X509Certificate[] chain = savingTrustManager.getChain(); + if ((chain == null) || (chain.length == 0)) { + throw new CertificateException("Could not capture server certificate chain from " + host + ":" + port); + } + + return chain[chain.length - 1]; + } + + /** + * Verifies that a TLS connection to the specified host and port can be established using the truststore at the given path by performing a TLS handshake. If + * the handshake is successful, the method returns normally. If the handshake fails due to trust issues, an SSLException is thrown. + * + * @param host the server host to connect to. + * @param port the server port to connect to. + * @param truststorePath the path to the truststore file to use for the TLS handshake. + * @throws Exception if an error occurs while verifying the connection, e.g. due to connection issues, TLS handshake failure, or if the truststore file + * cannot be loaded. + */ + public static void verifyConnectionWithTruststore(String host, int port, Path truststorePath) throws Exception { + KeyStore truststore = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(truststorePath)) { + truststore.load(in, CUSTOM_TRUSTSTORE_PASSWORD); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(truststore); + + SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); + sslContext.init(null, tmf.getTrustManagers(), new SecureRandom()); + + SSLSocketFactory socketFactory = sslContext.getSocketFactory(); + try (SSLSocket socket = (SSLSocket) socketFactory.createSocket(host, port)) { + socket.setSoTimeout(DEFAULT_TIMEOUT_MILLIS); + socket.startHandshake(); + } + } + + + /** + * Generates a human-readable description of the given X.509 certificate including subject, issuer, serial number, validity period, signature algorithm, and + * + * @param certificate the certificate to describe. + * @return a human-readable description of the given X.509 certificate. + */ + public static String describeCertificate(X509Certificate certificate) { + String nl = "\n"; + StringBuilder sb = new StringBuilder(); + sb.append("Subject: ").append(certificate.getSubjectX500Principal()).append(nl); + sb.append("Issuer : ").append(certificate.getIssuerX500Principal()).append(nl); + sb.append("Serial : ").append(certificate.getSerialNumber()).append(nl); + sb.append("Valid : ").append(certificate.getNotBefore()).append(" -> ").append(certificate.getNotAfter()).append(nl); + sb.append("SigAlg : ").append(certificate.getSigAlgName()).append(nl); + + Set critical = certificate.getCriticalExtensionOIDs(); + Set nonCritical = certificate.getNonCriticalExtensionOIDs(); + sb.append("Critical extensions : ").append((critical == null) ? "[]" : critical).append(nl); + sb.append("Non-critical extensions: ").append((nonCritical == null) ? "[]" : nonCritical); + + return sb.toString(); + } + + private static boolean containsCertificate(KeyStore keyStore, X509Certificate certificate) throws Exception { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate existing = keyStore.getCertificate(alias); + if ((existing instanceof X509Certificate existingX509) && Arrays.equals(existingX509.getEncoded(), certificate.getEncoded())) { + return true; + } + } + return false; + } + + private static String makeUniqueAlias(KeyStore keyStore, String baseAlias) throws Exception { + String alias = baseAlias; + int i = 1; + while (keyStore.containsAlias(alias)) { + alias = baseAlias + "-" + i; + i++; + } + return alias; + } + + private static final class SavingTrustManager implements X509TrustManager { + + private X509Certificate[] chain; + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // not needed + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + this.chain = (chain == null) ? null : Arrays.copyOf(chain, chain.length); + if ((chain == null) || (chain.length == 0)) { + throw new CertificateException("Server certificate chain is empty"); + } + throw new CertificateException("Captured server certificate chain"); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public X509Certificate[] getChain() { + return this.chain; + } + } +} diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index 758709d61..a516042c8 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -23,6 +23,9 @@ cmd.eclipse.detail=Eclipse IDE is an open-source Integrated Development Environm cmd.env=Prints the environment variables to set and export. cmd.env.detail=To print all active environment variables of IDEasy simply type: 'ide env'. If you add the '--debug' flag to ide e.g. 'ide --debug env' IDEasy will also add additional information about the locations of the definitions to the output. cmd.env.opt.--bash=Convert Windows path syntax to bash for usage in git-bash. +cmd.fix-vpn-tls-problem=Commandlet to fix the VPN TLS problem on Windows. +cmd.fix-vpn-tls-problem.detail=If you are using a VPN on Windows and encounter TLS problems, this commandlet can help you fix the issue by adding the necessary certificates to your Java keystore. Simply run the following command:\nide fix-vpn-tls-problem +cmd.fix-vpn-tls-problem.val.url=The URL that is affected by the VPN TLS problem (e.g. 'https://api.azul.com'), if not provided the commandlet will test 'https://github.com' by default. cmd.gcviewer=Tool commandlet for GC Viewer (View garbage collector logs of Java). cmd.gcviewer.detail=GCViewer is a tool for analyzing and visualizing Java garbage collection logs. Detailed documentation can be found at https://github.com/chewiebug/GCViewer cmd.get-edition=Get the edition of the selected tool. diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index e9e8fb846..124001ed4 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -23,6 +23,9 @@ cmd.eclipse.detail=Eclipse ist eine Open-Source-Entwicklungsumgebung für die En cmd.env=Gibt die zu setzenden und exportierenden Umgebungsvariablen aus. cmd.env.detail=Um alle aktiven Umgebungsvariablen von IDEasy auszugeben, geben Sie einfach 'ide env' in die Konsole ein. Um zusätzlich noch die Ursprünge der Variablen ausgegeben zu bekommen, fügen Sie einfach das debug flag '--debug' hinzu z.B. 'ide --debug env'. cmd.env.opt.--bash=Konvertiert Windows-Pfad-Syntax nach Bash zur Verwendung in git-bash. +cmd.fix-vpn-tls-problem=Wekzeug Kommando zum Beheben von VPN TLS Problemen auf Windows +cmd.fix-vpn-tls-problem.detail=Auf einigen Windows-Systemen kann es zu Problemen mit der TLS-Verbindung kommen, wenn eine VPN-Verbindung aktiv ist. Dieses Werkzeug Kommando bietet eine Lösung für dieses Problem, indem es die TLS-Einstellungen anpasst, um die Kompatibilität mit VPN-Verbindungen zu verbessern. Um dieses Problem zu beheben, führen Sie einfach den folgenden Befehl aus:\nide fix-vpn-tls-problem +cmd.fix-vpn-tls-problem.val.url=Die URL, die von dem VPN TLS Problem betroffen ist (e.g. 'https://api.azul.com'). Ohne eingabe einer URL wird standardmäßig: 'https://github.com' angefragt. cmd.gcviewer=Werkzeug Kommando für GC Viewer (Anzeige von Garbage-Collector Logs von Java). cmd.gcviewer.detail=GCViewer ist ein Tool zur Analyse und Visualisierung von Java-Garbage-Collection-Protokollen. Detaillierte Dokumentation ist zu finden unter https://github.com/chewiebug/GCViewer cmd.get-edition=Zeigt die Edition des selektierten Werkzeugs an. diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java index c6315a365..69ecfb01d 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java @@ -94,12 +94,31 @@ void testStatusWhenTlsIssue() throws Exception { // assert assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("Skipping check for newer version of IDEasy because you are offline."), - new IdeLogEntry(IdeLogLevel.ERROR, "You are offline because of the following error:", null, null, error, false), - IdeLogEntry.ofWarning( - "You are having TLS issues. We guess you are forced to use a VPN tool breaking end-to-end encryption causing this effect. As a workaround you can create and configure a truststore as described here:"), + new IdeLogEntry(IdeLogLevel.ERROR, "You are offline because of the following error:", null, null, error, false), IdeLogEntry.ofWarning( + "You are having TLS trust issues (PKIX/certificate-path/SSL handshake). As a workaround you can create and configure a truststore via the following command (replace with the failing endpoint):\nide fix-vpn-tls-problem "), IdeLogEntry.ofInteraction("https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues")); } + /** + * Tests the output if {@link StatusCommandlet} is run and online-check error message contains known PKIX trust issue text. + */ + @Test + void testStatusWhenPkixIssueInMessage() { + + // arrange + IdeTestContext context = new IdeTestContext(); + IllegalStateException error = new IllegalStateException("PKIX path building failed: unable to find valid certification path to requested target"); + context.getNetworkStatus().getOnlineCheck().set(error); + StatusCommandlet status = context.getCommandletManager().getCommandlet(StatusCommandlet.class); + + // act + status.run(); + + // assert + assertThat(context).logAtWarning().hasMessageContaining("ide fix-vpn-tls-problem "); + assertThat(context).logAtInteraction().hasMessageContaining("proxy-support.adoc#tls-certificate-issues"); + } + /** * Tests that the output of {@link StatusCommandlet} does not contain the username when run with active privacy mode on all OS (windows, linux, WSL, mac). * @@ -131,30 +150,9 @@ void testStatusWhenInPrivacyMode(String os, Path ideHome, Path ideRoot, Path use private static Stream providePrivacyModeTestCases() { return Stream.of( - Arguments.of( - "linux", - Path.of("/mnt/c/Users/testuser/projects/myproject"), - Path.of("/mnt/c/Users/testuser/projects"), - Path.of("/mnt/c/projects") - ), - Arguments.of( - "windows", - Path.of("C:\\Users\\testuser\\projects\\myproject"), - Path.of("C:\\Users\\testuser\\projects"), - Path.of("C:\\Users\\testuser") - ), - Arguments.of( - "linux", - Path.of("/home/testuser/projects/myproject"), - Path.of("/home/testuser/projects"), - Path.of("/home/testuser") - ), - Arguments.of( - "mac", - Path.of("/Users/testuser/projects/myproject"), - Path.of("/Users/testuser/projects"), - Path.of("/Users/testuser") - ) - ); + Arguments.of("linux", Path.of("/mnt/c/Users/testuser/projects/myproject"), Path.of("/mnt/c/Users/testuser/projects"), Path.of("/mnt/c/projects")), + Arguments.of("windows", Path.of("C:\\Users\\testuser\\projects\\myproject"), Path.of("C:\\Users\\testuser\\projects"), Path.of("C:\\Users\\testuser")), + Arguments.of("linux", Path.of("/home/testuser/projects/myproject"), Path.of("/home/testuser/projects"), Path.of("/home/testuser")), + Arguments.of("mac", Path.of("/Users/testuser/projects/myproject"), Path.of("/Users/testuser/projects"), Path.of("/Users/testuser"))); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java new file mode 100644 index 000000000..33d655813 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java @@ -0,0 +1,138 @@ +package com.devonfw.tools.ide.commandlet; + +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.Arrays; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.util.TruststoreUtil; + +/** + * Test of {@link TruststoreCommandlet}. + */ +class TruststoreCommandletTest extends AbstractIdeContextTest { + + private static final String IDE_OPTIONS = "IDE_OPTIONS"; + + + private String previousTruststore; + + private String previousTruststorePassword; + + @AfterEach + void cleanSystemProperties() { + + restoreSystemProperty("javax.net.ssl.trustStore", this.previousTruststore); + restoreSystemProperty("javax.net.ssl.trustStorePassword", this.previousTruststorePassword); + } + + @Test + void testRunWithInvalidEndpointThrowsCliException(@TempDir Path tempDir) { + + IdeTestContext context = newIsolatedContext(tempDir); + TruststoreCommandlet commandlet = context.getCommandletManager().getCommandlet(TruststoreCommandlet.class); + setUrl(commandlet, context, "http://github.com"); + + assertThatThrownBy(commandlet::run).isInstanceOf(CliException.class) + .hasMessageContaining("Invalid target URL/host") + .hasMessageContaining("Only HTTPS URLs are supported"); + } + + @Test + void testRunWithUnreachableEndpointLogsHintAndKeepsTruststoreUntouched(@TempDir Path tempDir) { + + IdeTestContext context = newIsolatedContext(tempDir); + TruststoreCommandlet commandlet = context.getCommandletManager().getCommandlet(TruststoreCommandlet.class); + setUrl(commandlet, context, "https://127.0.0.1:9"); + + Path customTruststorePath = context.getSettingsPath().resolve("truststore").resolve("truststore.p12"); + commandlet.run(); + + assertThat(customTruststorePath).doesNotExist(); + assertThat(context).logAtInfo().hasMessageContaining("is not reachable/valid without certificate changes"); + assertThat(context).logAtInteraction().hasMessageContaining("proxy-support.adoc#tls-certificate-issues"); + } + + @Test + void testConfigureIdeOptionsReplacesExistingTruststoreOptions(@TempDir Path tempDir) throws Exception { + + IdeTestContext context = newIsolatedContext(tempDir); + TruststoreCommandlet commandlet = context.getCommandletManager().getCommandlet(TruststoreCommandlet.class); + rememberSystemProperties(); + + EnvironmentVariables userVariables = context.getVariables().getByType(EnvironmentVariablesType.USER); + userVariables.set(IDE_OPTIONS, + "-Xmx512m -Djavax.net.ssl.trustStore=/old/path.p12 -Djavax.net.ssl.trustStorePassword=old-secret"); + + Path newTruststorePath = context.getSettingsPath().resolve("truststore").resolve("truststore.p12"); + + invokeConfigureIdeOptions(commandlet, newTruststorePath); + + String options = userVariables.getFlat(IDE_OPTIONS); + String expectedPassword = Arrays.toString(TruststoreUtil.CUSTOM_TRUSTSTORE_PASSWORD); + + assertThat(options).contains("-Xmx512m"); + assertThat(options).contains("-Djavax.net.ssl.trustStore=" + newTruststorePath.toAbsolutePath()); + assertThat(options).contains("-Djavax.net.ssl.trustStorePassword=" + expectedPassword); + assertThat(options).doesNotContain("/old/path.p12"); + assertThat(options).doesNotContain("old-secret"); + + assertThat(System.getProperty("javax.net.ssl.trustStore")).isEqualTo(newTruststorePath.toAbsolutePath().toString()); + assertThat(System.getProperty("javax.net.ssl.trustStorePassword")).isEqualTo(expectedPassword); + } + + private IdeTestContext newIsolatedContext(Path tempDir) { + + IdeTestContext context = newContext(PROJECT_BASIC); + Path isolatedSettingsPath = tempDir.resolve("settings"); + assertThat(isolatedSettingsPath.startsWith(tempDir)).isTrue(); + context.setSettingsPath(isolatedSettingsPath); + assertThat(context.getSettingsPath()).isEqualTo(isolatedSettingsPath); + return context; + } + + private static void setUrl(TruststoreCommandlet commandlet, IdeTestContext context, String url) { + + Property endpointValue = commandlet.getValues().get(1); + endpointValue.setValueAsString(url, context); + } + + private static void invokeConfigureIdeOptions(TruststoreCommandlet commandlet, Path customTruststorePath) throws Exception { + + Method method = TruststoreCommandlet.class.getDeclaredMethod("configureIdeOptions", Path.class); + method.setAccessible(true); + method.invoke(commandlet, customTruststorePath); + } + + private void rememberSystemProperties() { + + this.previousTruststore = System.getProperty("javax.net.ssl.trustStore"); + this.previousTruststorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + } + + private static void restoreSystemProperty(String key, String value) { + + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + +} + + + + + + + diff --git a/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java b/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java new file mode 100644 index 000000000..79a75c4da --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java @@ -0,0 +1,196 @@ +package com.devonfw.tools.ide.truststore; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.io.OutputStream; +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.Arrays; +import java.util.Enumeration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.util.TruststoreUtil; + +/** + * Test of {@link TruststoreUtil}. + */ +class TruststoreUtilTest { + + private static final String PASSWORD = "changeit"; + + private static final String TEST_CERT_RESOURCE = "/truststore/test-cert.pem"; + + @TempDir + Path tempDir; + + @Test + void testParseTlsEndpointFromHttpsUrl() { + + TruststoreUtil.TlsEndpoint endpoint = TruststoreUtil.parseTlsEndpoint("https://github.com/tools/path"); + + assertThat(endpoint.host()).isEqualTo("github.com"); + assertThat(endpoint.port()).isEqualTo(443); + } + + @Test + void testParseTlsEndpointFromHostAndPort() { + + TruststoreUtil.TlsEndpoint endpoint = TruststoreUtil.parseTlsEndpoint("my-host.local:8443"); + + assertThat(endpoint.host()).isEqualTo("my-host.local"); + assertThat(endpoint.port()).isEqualTo(8443); + } + + @Test + void testParseTlsEndpointRejectsHttp() { + + assertThatThrownBy(() -> TruststoreUtil.parseTlsEndpoint("http://github.com")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Only HTTPS URLs are supported"); + } + + @Test + void testIsTruststorePresent() { + + Path path = this.tempDir.resolve("truststore.p12"); + assertThat(TruststoreUtil.isTruststorePresent(path)).isFalse(); + + writeEmptyTruststore(path); + + assertThat(TruststoreUtil.isTruststorePresent(path)).isTrue(); + } + + @Test + void testCopyTruststore() throws Exception { + + X509Certificate certificate = loadCertificateFromResource(); + KeyStore source = KeyStore.getInstance("PKCS12"); + source.load(null, PASSWORD.toCharArray()); + source.setCertificateEntry("source-cert", certificate); + + KeyStore target = KeyStore.getInstance("PKCS12"); + target.load(null, PASSWORD.toCharArray()); + + TruststoreUtil.copyTruststore(source, target); + + assertThat(target.getCertificate("source-cert")).isNotNull(); + } + + @Test + void testCreateOrUpdateTruststoreAddsCertificateOnlyOnce() throws Exception { + + Path truststorePath = this.tempDir.resolve("custom-existing.p12"); + writeEmptyTruststore(truststorePath); + + X509Certificate certificate = loadCertificateFromResource(); + + TruststoreUtil.createOrUpdateTruststore(truststorePath, certificate, "custom"); + int countAfterFirstAdd = countCertificateOccurrences(truststorePath, certificate); + + TruststoreUtil.createOrUpdateTruststore(truststorePath, certificate, "custom"); + int countAfterSecondAdd = countCertificateOccurrences(truststorePath, certificate); + + assertThat(countAfterFirstAdd).isEqualTo(1); + assertThat(countAfterSecondAdd).isEqualTo(1); + } + + @Test + void testCreateOrUpdateTruststoreCreatesFileIfMissing() throws Exception { + + Path truststorePath = this.tempDir.resolve("nested").resolve("custom-new.p12"); + + TruststoreUtil.createOrUpdateTruststore(truststorePath, loadCertificateFromResource(), "custom"); + + assertThat(truststorePath).exists(); + KeyStore truststore = loadTruststore(truststorePath); + assertThat(truststore.size()).isGreaterThan(0); + } + + @Test + void testDescribeCertificateContainsExpectedSections() throws Exception { + + X509Certificate certificate = loadCertificateFromResource(); + + String description = TruststoreUtil.describeCertificate(certificate); + + assertThat(description).contains("Subject:"); + assertThat(description).contains("Issuer :"); + assertThat(description).contains("Serial :"); + assertThat(description).contains("SigAlg :"); + } + + @Test + void testFetchServerCertificateValidatesInput() { + + assertThatThrownBy(() -> TruststoreUtil.fetchServerCertificate(null, 443)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> TruststoreUtil.fetchServerCertificate(" ", 443)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host must not be blank"); + assertThatThrownBy(() -> TruststoreUtil.fetchServerCertificate("github.com", 0)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("port must be between 1 and 65535"); + } + + @Test + void testLoadCertificateFromResource() throws Exception { + + X509Certificate certificate = loadCertificateFromResource(); + + assertThat(certificate.getSubjectX500Principal().getName()).contains("CN=IDEasy Test Cert"); + } + + private static X509Certificate loadCertificateFromResource() throws Exception { + + try (InputStream in = TruststoreUtilTest.class.getResourceAsStream(TEST_CERT_RESOURCE)) { + assertThat(in).as("Test certificate resource must exist: " + TEST_CERT_RESOURCE).isNotNull(); + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) certificateFactory.generateCertificate(in); + } + } + + private static void writeEmptyTruststore(Path path) { + + try { + Files.createDirectories(path.getParent()); + KeyStore truststore = KeyStore.getInstance("PKCS12"); + truststore.load(null, PASSWORD.toCharArray()); + try (OutputStream out = Files.newOutputStream(path)) { + truststore.store(out, PASSWORD.toCharArray()); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to initialize empty truststore for test: " + path, e); + } + } + + private static KeyStore loadTruststore(Path truststorePath) throws Exception { + + KeyStore truststore = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(truststorePath)) { + truststore.load(in, PASSWORD.toCharArray()); + } + return truststore; + } + + private static int countCertificateOccurrences(Path truststorePath, X509Certificate certificate) throws Exception { + + KeyStore truststore = loadTruststore(truststorePath); + Enumeration aliases = truststore.aliases(); + int count = 0; + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (truststore.getCertificate(alias) instanceof X509Certificate existing && Arrays.equals(existing.getEncoded(), certificate.getEncoded())) { + count++; + } + } + return count; + } + +} + + + + diff --git a/cli/src/test/resources/truststore/test-cert.pem b/cli/src/test/resources/truststore/test-cert.pem new file mode 100644 index 000000000..3cd16017a --- /dev/null +++ b/cli/src/test/resources/truststore/test-cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIJAOr4Zs6LtSLYMA0GCSqGSIb3DQEBDAUAMGcxCzAJBgNV +BAYTAkRFMQ0wCwYDVQQIEwRUZXN0MQ0wCwYDVQQHEwRUZXN0MQ8wDQYDVQQKEwZJ +REVhc3kxDjAMBgNVBAsTBVRlc3RzMRkwFwYDVQQDExBJREVhc3kgVGVzdCBDZXJ0 +MB4XDTI2MDMzMDA5MDE0N1oXDTM2MDMyNzA5MDE0N1owZzELMAkGA1UEBhMCREUx +DTALBgNVBAgTBFRlc3QxDTALBgNVBAcTBFRlc3QxDzANBgNVBAoTBklERWFzeTEO +MAwGA1UECxMFVGVzdHMxGTAXBgNVBAMTEElERWFzeSBUZXN0IENlcnQwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDgBKqwCWouR8arcukc14Q7LZ6ozwZP +RNzyFGx+v4pDBUY1QTlcYsQMpOUeyfsgo6WfX6ts743T95/Whh9EHmNP3jqad3o3 +tuWCWG6KyXtbv5BVUZa0qdgiVEjKizXaTW/IQiDMvNba+wnGzcH3k2V1J/1o9g0e +XABZdyG3eSg5XACpguWDXrHgq3P9+V5G0r72z215U5kB9bI5CQEocNEf0n3LFSrL +RvJ0+8Gm9ClsVRnsAw8IRaSsMO7A0K9lnvtnScbJr1zswBUMBOk3VqzdySXdxyeN +V/kO9nKuVY125r5sVDibo5uIIOdBWch6LkDIjrem1KKoHcCH5F22Q9kJAgMBAAGj +ITAfMB0GA1UdDgQWBBSBWH+GGyMGZpuktXrmuoOwZveR/jANBgkqhkiG9w0BAQwF +AAOCAQEAuFxxmJkKbQV0jRspb1yymgQRgL59+PEtWa0lDgkX4Zrk/8S/4B7+/rLR +8rRdlIFbSvAtmeUHCU59xto77N/hYGT0HLTDDbKBO5dYvEa3BKUZcHu/hBhSgp2A +0ggng3PH3p93gReEdvxFqDiP2Uf8wPj735JPQUiMAYPWGQ87jMss+nUmp5m5T8MQ +z5WrNg9QJG7Zg64qoK37oOmueCGtxBnyUtJ2ZvES5HS7cXLRwAezWvoYDFql2JQG +ghhJayeBO1iTM/SxnmWNg+dGGABqw9+00Tt2qE5sPsdOjaPVIcrqb2njPYUS5FTD +3xMnN+dAwg2lak+bV4crWvSEqV7psQ== +-----END CERTIFICATE----- +