diff --git a/src/main/java/de/mlessmann/certassist/keystore/AutoBootKeyStoreManagement.java b/src/main/java/de/mlessmann/certassist/keystore/AutoBootKeyStoreManagement.java index b1a4035..b87c16b 100644 --- a/src/main/java/de/mlessmann/certassist/keystore/AutoBootKeyStoreManagement.java +++ b/src/main/java/de/mlessmann/certassist/keystore/AutoBootKeyStoreManagement.java @@ -17,10 +17,4 @@ public class AutoBootKeyStoreManagement { ) { return new KeyStoreManager(certificateCreator, passwordProvider); } - - @Bean - @ConditionalOnMissingBean(TruststoreManager.class) - public TruststoreManager truststoreProvider() { - return new TruststoreManager(); - } } diff --git a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java index 93da782..23f446e 100644 --- a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java +++ b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java @@ -13,8 +13,13 @@ import java.security.*; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -27,25 +32,21 @@ public class KeyStoreManager { StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, }; - private final OpenSSLService certificateCreator; + private final OpenSSLService openSSLService; private final CertificatePasswordProvider passwordProvider; + private final AtomicReference certFactory = new AtomicReference<>(); public KeystoreUsage createKeyStore(String keyStorePassphrase, CertificateUsage... serverCerts) throws JavaSecurityException { try { Path keystorePath = Files.createTempFile("keystore", ".jks"); - - // Load the keystore + keystorePath.toFile().deleteOnExit(); + log.debug("Creating keyStore at {}", keystorePath); KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); - keystore.load(null, null); + keystore.load(null, keyStorePassphrase.toCharArray()); for (CertificateUsage serverCert : serverCerts) { - PrivateKey privateKey = loadPrivateKey( - serverCert.certificateKeyPath(), - passwordProvider.getPasswordFor(serverCert.fingerprint()) - ); - Certificate[] certChain = loadCertificateChain(serverCert.fullchainPath()); - keystore.setKeyEntry(serverCert.fingerprint(), privateKey, null, certChain); + loadPrivateKeyIntoStore(keystore, serverCert); } // Save the keystore @@ -58,23 +59,80 @@ public class KeyStoreManager { } } + @SneakyThrows + public KeystoreUsage createTruststore(String truststorePassphrase, CertificateUsage... trustedCertificates) { + return createTruststore(truststorePassphrase, Arrays.asList(trustedCertificates)); + } + + public KeystoreUsage createTruststore( + String truststorePassphrase, + Collection trustedCertificates + ) { + try { + Path truststorePath = Files.createTempFile("truststore", ".jks"); + truststorePath.toFile().deleteOnExit(); + log.debug("Creating truststore at {}", truststorePath); + KeyStore truststore = KeyStore.getInstance(KeyStore.getDefaultType()); + truststore.load(null, truststorePassphrase.toCharArray()); + + for (CertificateUsage serverCert : trustedCertificates) { + loadPublicKeyIntoTrust(truststore, serverCert); + } + + try (var outputStream = Files.newOutputStream(truststorePath, CREATE_TRUNCATE)) { + truststore.store(outputStream, truststorePassphrase.toCharArray()); + } + return new KeystoreResult(truststorePath); + } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException e) { + throw new IllegalStateException("Failed to create truststore!", e); + } + } + + public void loadPrivateKeyIntoStore(KeyStore keyStore, CertificateUsage cert) throws KeyStoreException { + String passphrase = passwordProvider.getPasswordFor(cert.fingerprint()); + var pKey = loadPrivateKey( + Objects.requireNonNull(cert.certificateKeyPath(), "Certificate does not have a key to import."), + passphrase + ); + var certChain = loadCertificateChain(cert.fullchainPath()); + keyStore.setKeyEntry("KEY;%s".formatted(cert.fingerprint()), pKey, null, certChain); + } + + public void loadPublicKeyIntoTrust(KeyStore keyStore, CertificateUsage cert) throws KeyStoreException { + try (var inputStream = Files.newInputStream(cert.certificatePath())) { + Collection parsedCerts = getX509Factory().generateCertificates(inputStream); + if (parsedCerts.size() != 1) { + throw new IllegalStateException( + "CertificateUsage parsed to not exactly one result! " + parsedCerts.size() + ); + } + Certificate parsedCert = parsedCerts.stream().toList().getFirst(); + if (!(parsedCert instanceof X509Certificate x509cert)) { + throw new IllegalStateException("CertificateUsage did not parse to X509 format??"); + } + keyStore.setCertificateEntry("TRUST;%s".formatted(cert.fingerprint()), x509cert); + } catch (IOException e) { + throw new IllegalStateException("CertificateUsage could not be read??", e); + } catch (CertificateException e) { + throw new IllegalStateException("CertificateUsage is not a parsable certificate??"); + } + } + @SneakyThrows private PrivateKey loadPrivateKey(Path privateKey, String passphrase) { String pemContent; - if (certificateCreator.isKeyEncrypted(privateKey)) { - pemContent = certificateCreator.readDecryptedKey(privateKey, passphrase); + if (openSSLService.isKeyEncrypted(privateKey)) { + pemContent = openSSLService.readDecryptedKey(privateKey, passphrase); } else { pemContent = Files.readString(privateKey); } - try (var fis = Files.newInputStream(privateKey)) { - String privateKeyPEM = pemContent - .replaceAll(".*?-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - byte[] decodedKey = Base64.getDecoder().decode(privateKeyPEM); - return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decodedKey)); - } + String privateKeyPEM = pemContent + .replaceAll(".*?-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] decodedKey = Base64.getDecoder().decode(privateKeyPEM); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decodedKey)); } @SneakyThrows @@ -85,4 +143,17 @@ public class KeyStoreManager { return certFactory.generateCertificates(fis).toArray(Certificate[]::new); } } + + protected final synchronized CertificateFactory getX509Factory() { + CertificateFactory instance = certFactory.get(); + if (instance == null) { + try { + instance = CertificateFactory.getInstance("X.509"); + certFactory.set(instance); + } catch (CertificateException e) { + throw new IllegalStateException("X.509 factory missing??", e); + } + } + return instance; + } } diff --git a/src/main/java/de/mlessmann/certassist/keystore/TruststoreManager.java b/src/main/java/de/mlessmann/certassist/keystore/TruststoreManager.java deleted file mode 100644 index 131ecbd..0000000 --- a/src/main/java/de/mlessmann/certassist/keystore/TruststoreManager.java +++ /dev/null @@ -1,62 +0,0 @@ -package de.mlessmann.certassist.keystore; - -import de.mlessmann.certassist.openssl.CertificateUsage; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.OpenOption; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -public class TruststoreManager { - - private static final OpenOption[] CREATE_TRUNCATE = { - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - }; - - public KeystoreUsage createTruststore(String truststorePassphrase, CertificateUsage... trustedCertificates) { - try { - Path truststorePath = Files.createTempFile("truststore", ".jks"); - truststorePath.toFile().deleteOnExit(); - log.debug("Creating truststore at {}", truststorePath); - - KeyStore truststore = buildTruststore(trustedCertificates); - try (var outputStream = Files.newOutputStream(truststorePath, CREATE_TRUNCATE)) { - truststore.store(outputStream, truststorePassphrase.toCharArray()); - } - return new KeystoreResult(truststorePath); - } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException e) { - throw new IllegalStateException("Failed to create truststore!", e); - } - } - - @SneakyThrows - private static KeyStore buildTruststore(CertificateUsage[] trustedCertificates) { - KeyStore truststore = KeyStore.getInstance(KeyStore.getDefaultType()); - truststore.load(null, null); - CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - - if (trustedCertificates == null || trustedCertificates.length == 0) { - log.warn("No trusted certificates provided, truststore will be empty!"); - } else { - for (CertificateUsage trustedCertificate : trustedCertificates) { - try (var inputStream = Files.newInputStream(trustedCertificate.certificatePath())) { - X509Certificate jdkCert = (X509Certificate) certificateFactory.generateCertificate(inputStream); - truststore.setCertificateEntry(trustedCertificate.fingerprint(), jdkCert); - } - } - } - return truststore; - } -} diff --git a/src/main/java/de/mlessmann/certassist/models/Certificate.java b/src/main/java/de/mlessmann/certassist/models/Certificate.java index a92484a..a61f058 100644 --- a/src/main/java/de/mlessmann/certassist/models/Certificate.java +++ b/src/main/java/de/mlessmann/certassist/models/Certificate.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import lombok.Setter; @Entity +@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "fingerprint" }) }) @Data @RequiredArgsConstructor public class Certificate { @@ -50,12 +51,13 @@ public class Certificate { private byte[] cert = new byte[0]; //@Lob - Cannot annotate column: https://github.com/xerial/sqlite-jdbc/issues/135 - @Column(nullable = false) + @Column private byte[] privateKey = new byte[0]; //@Lob - Cannot annotate column: https://github.com/xerial/sqlite-jdbc/issues/135 @Column private byte[] fullchain; + @Column(nullable = false) private String fingerprint; } diff --git a/src/main/java/de/mlessmann/certassist/openssl/CertificateUsage.java b/src/main/java/de/mlessmann/certassist/openssl/CertificateUsage.java index 1974fd1..0fcecd6 100644 --- a/src/main/java/de/mlessmann/certassist/openssl/CertificateUsage.java +++ b/src/main/java/de/mlessmann/certassist/openssl/CertificateUsage.java @@ -16,9 +16,11 @@ public interface CertificateUsage extends AutoCloseable { /** * Returns the path to the private key file (on disk, potentially temporary depending on the storage implementation). - * This file should also be encrypted. + * This file should also be encrypted as the file system is considered in-flight. * @see CertificatePasswordProvider + * @apiNote Return value can be null, when there is no private key (e.g. imported certificates for trust only) */ + @Nullable Path certificateKeyPath(); /** diff --git a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java index eb5aa94..294a623 100644 --- a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java @@ -1,7 +1,9 @@ package de.mlessmann.certassist.openssl; import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.*; +import static lombok.AccessLevel.PRIVATE; import static org.slf4j.LoggerFactory.getLogger; import de.mlessmann.certassist.DeleteRecursiveFileVisitor; @@ -13,14 +15,14 @@ import de.mlessmann.certassist.models.CertificateInfoExtension; import de.mlessmann.certassist.models.CertificateInfoSubject; import de.mlessmann.certassist.models.CertificateInfoSubject.CertificateInfoSubjectBuilder; import de.mlessmann.certassist.service.ExecutableResolver; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.security.cert.X509Certificate; +import java.util.*; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -28,11 +30,14 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.zeroturnaround.exec.ProcessExecutor; @@ -45,9 +50,9 @@ import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; @Slf4j public class OpenSSLService { - private static final Logger openSSLLogger = getLogger("OpenSSL-Logger"); public static final String OPENSSL_CERT_SUBJECT_TEMPLATE = "/C=ISO-COUNTRY/ST=STATE/L=LOCALITY/O=ORGANIZATION/CN=COMMON-NAME"; + private static final Logger openSSLLogger = getLogger("OpenSSL-Logger"); private static final String CSR_EXT_TEMPLATE = """ authorityKeyIdentifier=keyid,issuer @@ -105,6 +110,12 @@ public class OpenSSLService { } } + private static void requireNotBlank(@Nullable String str, @NonNull String failMessage) { + if (StringUtils.isBlank(str)) { + throw new IllegalArgumentException(failMessage); + } + } + @NonNull public OpenSSLCertificateResult createCertificate(CertificateInfo request) throws CommandLineOperationException { Path tmpDir; @@ -126,11 +137,16 @@ public class OpenSSLService { } try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) { + Path pKeyPath = requireNonNull( + certAuthority.certificateKeyPath(), + "Cannot sign a certificate with a CA for which the key is not available!" + ); + Path signingRequest = createSigningRequest(request, keyFile, tmpDir.resolve("child.csr"), keyPassphrase); Path signedCert = signCertificate( request, certAuthority.certificatePath(), - certAuthority.certificateKeyPath(), + pKeyPath, passwordProvider.getPasswordFor(certAuthority.fingerprint()), signingRequest ); @@ -153,11 +169,14 @@ public class OpenSSLService { } } - private Path createKeyfile(CertificateInfo request, Path outFile, String filePassword) + @NonNull + private Path createKeyfile(@NonNull CertificateInfo request, @NonNull Path outFile, @NonNull String keyPassphrase) throws CommandLineOperationException { + requireNotBlank(keyPassphrase, "A passphrase must be provided to generate private keys!"); + requireNonNull(outFile, "Output file must be provided to generate keys!"); Path keyFile = outFile.toAbsolutePath(); - log.debug("Writing new certificate key to {}", keyFile); + log.debug("Writing new certificate key to {}", keyFile); StartedProcess keygenProc = null; try { keygenProc = @@ -172,7 +191,7 @@ public class OpenSSLService { OSSL_ARG_KEY_PW, Integer.toString(request.getRequestedKeyLength()) ) - .environment(OSSL_ENV_KEY_PW, filePassword) + .environment(OSSL_ENV_KEY_PW, keyPassphrase) .redirectOutput(Slf4jStream.of(openSSLLogger).asDebug()) .redirectError(Slf4jStream.of(openSSLLogger).asError()) .start(); @@ -185,10 +204,18 @@ public class OpenSSLService { return keyFile; } - private Path createCertificate(CertificateInfo request, Path keyFile, Path outFile, String keyPassphrase) - throws CommandLineOperationException { - log.debug("Writing new certificate file {}", outFile); + @NonNull + private Path createCertificate( + @NonNull CertificateInfo request, + @NonNull Path keyFile, + Path outFile, + @NonNull String keyPassphrase + ) throws CommandLineOperationException { + requireNonNull(outFile, "Output file must be provided to generate certificates!"); + requireNonNull(keyFile, "Private key file must be provided to generate certificates!"); + requireNotBlank(keyPassphrase, "Private key passphrase must be provided to generate certificates."); + log.debug("Writing new certificate file {}", outFile); String certSubject = buildSubjectArg(request); StartedProcess certGenProc = null; try { @@ -225,9 +252,17 @@ public class OpenSSLService { return outFile; } - private Path createSigningRequest(CertificateInfo request, Path keyFile, Path outFile, String certPassword) - throws CommandLineOperationException { - log.atDebug().log("Writing new certificate signing request file {}", outFile); + @NonNull + private Path createSigningRequest( + @NonNull CertificateInfo request, + @NonNull Path keyFile, + @NonNull Path outFile, + @NonNull String pkeyPassphrase + ) throws CommandLineOperationException { + requireNonNull(keyFile, "Private key must be provided to generate CSR!"); + requireNonNull(outFile, "Output file must be provided to generate CSR!"); + requireNotBlank(pkeyPassphrase, "Passphrase for private key must be provided to create CSR."); + log.debug("Writing new certificate signing request file {}", outFile); String certSubject = buildSubjectArg(request); StartedProcess certGenProc = null; @@ -249,7 +284,7 @@ public class OpenSSLService { "-subj", certSubject ) - .environment(OSSL_ENV_KEY_PW, certPassword) + .environment(OSSL_ENV_KEY_PW, pkeyPassphrase) .redirectOutput(Slf4jStream.of(openSSLLogger).asDebug()) .redirectError(Slf4jStream.of(openSSLLogger).asError()) .start(); @@ -270,10 +305,10 @@ public class OpenSSLService { public boolean verifyCertificate(@NonNull Path fullChainFile, @NonNull List trustedCAs) throws CommandLineOperationException { if (CollectionUtils.isEmpty(trustedCAs)) { - throw new IllegalArgumentException( - "At least one trusted CA certificate must be provided to run the verification command." - ); + log.warn("No trusted CA provided for verification. Certificate is untrusted."); + return false; } + requireNonNull(fullChainFile, "Cannot verify certificate without fullChainFile"); Path tmpDir = null; StartedProcess verifyCommand = null; @@ -335,10 +370,13 @@ public class OpenSSLService { /** * Verifies a passphrase against a provided key. + * * @implNote Due to the implementation of the OpenSSL cli, any password will be valid for unencrypted keys. (Check with {@link #isKeyEncrypted(Path).) */ public boolean verifyKeyPassphrase(@NonNull Path keyFile, @NonNull String passphrase) throws CommandLineOperationException { + requireNonNull(keyFile, "Key file must be provided to check encryption."); + requireNonNull(passphrase, "Must provide a passphrase to check encryption."); StartedProcess verifyCommand = null; try { verifyCommand = @@ -365,16 +403,22 @@ public class OpenSSLService { } } + @NonNull private Path signCertificate( CertificateInfo request, - Path caCert, - Path caKey, - String caKeyPassphrase, - Path csrFile + @NonNull Path caCert, + @NonNull Path caKey, + @NonNull String caKeyPassphrase, + @NonNull Path csrFile ) throws CommandLineOperationException { + requireNonNull(caCert, "CA certificate must be provided to sign certs!"); + requireNonNull(caKey, "CA private key must be provided to sign certs!"); + requireNotBlank(caKeyPassphrase, "CA private key passphrase must be provided to sign certs!"); + requireNonNull(csrFile, "CSR to be signed must be provided."); + Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt")); - log.debug("Writing new signed certificate file {}", outFile); Path extFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".ext")); + log.debug("Writing new signed certificate file {}", outFile); try { String extContent = CSR_EXT_TEMPLATE; @@ -394,7 +438,7 @@ public class OpenSSLService { extContent = extContent.replaceAll("\\[alt_names]\n?", ""); } - log.debug("Writing extension file content: \n {}", extContent); + log.debug("Writing extension file {} with content: \n {}", extFile, extContent); Files.writeString( extFile, extContent, @@ -449,42 +493,64 @@ public class OpenSSLService { return outFile; } - public String getCertificateFingerprint(Path certificate) throws CommandLineOperationException { - StartedProcess fingerprintProc = null; + @NonNull + public String getCertificateFingerprint(@NonNull Path certificate) throws CommandLineOperationException { + requireNonNull(certificate, "Certificate must be provided to generate fingerprint."); + try { - fingerprintProc = - new ProcessExecutor() - .command(resolveOpenSSL(), "x509", "-in", certificate.toString(), "-noout", "-fingerprint") - .readOutput(true) - .redirectError(Slf4jStream.of(openSSLLogger).asError()) - .start(); + return getCertificateFingerprint(Files.readString(certificate)); + } catch (IOException e) { + throw new CommandLineOperationException("Certificate content could not be read.", e); + } + } + + private String extractFingerprintFromOutput(ProcessResult fingerprintResult) throws CommandLineOperationException { + String output = fingerprintResult.getOutput().getUTF8(); + + if (fingerprintResult.getExitValue() != 0) { + log.debug("Fingerprint command output:\n{}", output); + throw new CommandLineOperationException( + "Failed to get fingerprint of certificate. Exit code: %d".formatted(fingerprintResult.getExitValue()) + ); + } + + Matcher matcher = FINGERPRINT_EXTRACTOR.matcher(output); + if (!matcher.find()) { + log.debug(output); + throw new CommandLineOperationException( + "Unexpected output of fingerprint command. (See log for more details)" + ); + } + String algorithm = matcher.group("algo"); + String fingerprint = matcher.group("finger"); + if (StringUtils.isBlank(algorithm) || StringUtils.isBlank(fingerprint)) { + throw new CommandLineOperationException( + "Unexpected output of fingerprint command: %s %s".formatted(algorithm, fingerprint) + ); + } + return "%s;%s".formatted(algorithm, fingerprint); + } + + @NonNull + @SneakyThrows + public String getCertificateFingerPrint(X509Certificate jdkCert) { + String pemContent = + "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----".formatted( + new String(jdkCert.getEncoded(), StandardCharsets.UTF_8) + ); + return getCertificateFingerprint(pemContent); + } + + @NonNull + @SneakyThrows + public String getCertificateFingerprint(@NonNull String pemContent) throws CommandLineOperationException { + requireNonNull(pemContent, "Certificate PEM content must be provided to generate fingerprint from string."); + + StartedProcess fingerprintProc = null; + try (var input = new ByteArrayInputStream(pemContent.getBytes())) { + fingerprintProc = Commands.fingerprintCommand(resolveOpenSSL()).redirectInput(input).start(); var fingerprintResult = fingerprintProc.getFuture().get(30, SECONDS); - String output = fingerprintResult.getOutput().getUTF8(); - - if (fingerprintResult.getExitValue() != 0) { - log.debug("Fingerprint command output:\n{}", output); - throw new CommandLineOperationException( - "Failed to get fingerprint of certificate. Exit code: %d".formatted( - fingerprintResult.getExitValue() - ) - ); - } - - Matcher matcher = FINGERPRINT_EXTRACTOR.matcher(output); - if (!matcher.find()) { - log.debug(output); - throw new CommandLineOperationException( - "Unexpected output of fingerprint command. (See log for more details)" - ); - } - String algorithm = matcher.group("algo"); - String fingerprint = matcher.group("finger"); - if (StringUtils.isBlank(algorithm) || StringUtils.isBlank(fingerprint)) { - throw new CommandLineOperationException( - "Unexpected output of fingerprint command: %s %s".formatted(algorithm, fingerprint) - ); - } - return "%s;%s".formatted(algorithm, fingerprint); + return extractFingerprintFromOutput(fingerprintResult); } catch (IOException | ExecutionException | TimeoutException | InterruptedException e) { throw new RuntimeException(e); } finally { @@ -492,37 +558,12 @@ public class OpenSSLService { } } - public CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { + @NonNull + @SneakyThrows + public CertificateInfo getCertificateInfo(String pemContent) { StartedProcess infoProc = null; - try { - infoProc = - new ProcessExecutor() - .command( - resolveOpenSSL(), - "x509", - "-in", - path.toString(), - "-noout", - "-dateopt", - "iso_8601", - "-fingerprint", - "-subject", - "-issuer", - "-serial", - "-dates", - "-alias", - "-email", - "-purpose", - "-ext", - "subjectAltName", - "-nameopt", - "sep_multiline", - "-nameopt", - "lname" - ) - .readOutput(true) - .redirectError(Slf4jStream.of(openSSLLogger).asError()) - .start(); + try (var input = new ByteArrayInputStream(pemContent.getBytes())) { + infoProc = Commands.infoCommand(resolveOpenSSL()).redirectInput(input).start(); var infoResult = infoProc.getFuture().get(30, SECONDS); String output = infoResult.getOutput().getUTF8(); if (infoResult.getExitValue() != 0) { @@ -534,9 +575,22 @@ public class OpenSSLService { return getCertificateInfo(output.lines().toArray(String[]::new)); } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { throw new RuntimeException(e); + } finally { + killIfActive(infoProc); } } + @NonNull + public CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { + requireNonNull(path, "Certificate file must be provided to read the info."); + try { + return getCertificateInfo(Files.readString(path)); + } catch (IOException e) { + throw new CommandLineOperationException("Failed to read certificate file.", e); + } + } + + @NonNull private String resolveOpenSSL() throws CommandLineOperationException { try { String path = executableResolver.getOpenSSLPath(); @@ -621,7 +675,12 @@ public class OpenSSLService { }; } - public String readDecryptedKey(Path keyFile, String passphrase) throws CommandLineOperationException { + @NonNull + public String readDecryptedKey(@NonNull Path keyFile, @NonNull String passphrase) + throws CommandLineOperationException { + requireNonNull(keyFile, "Key to be decrypted must be provided!"); + requireNotBlank(passphrase, "Passphrase for private key must be provided to run decryption."); + StartedProcess keyReadProc = null; try { keyReadProc = @@ -646,4 +705,48 @@ public class OpenSSLService { killIfActive(keyReadProc); } } + + @NoArgsConstructor(access = PRIVATE) + private static class Commands { + + private static ProcessExecutor infoCommand(String openSSL, String... additArgs) { + List command = new ArrayList<>( + List.of( + openSSL, + "x509", + "-noout", + "-dateopt", + "iso_8601", + "-fingerprint", + "-subject", + "-issuer", + "-serial", + "-dates", + "-alias", + "-email", + "-purpose", + "-ext", + "subjectAltName", + "-nameopt", + "sep_multiline", + "-nameopt", + "lname" + ) + ); + command.addAll(Arrays.asList(additArgs)); + return new ProcessExecutor() + .command(command) + .readOutput(true) + .redirectError(Slf4jStream.of(openSSLLogger).asError()); + } + + private static ProcessExecutor fingerprintCommand(String openSSL, String... additArgs) { + List command = new ArrayList<>(List.of(openSSL, "x509", "-noout", "-fingerprint")); + command.addAll(Arrays.asList(additArgs)); + return new ProcessExecutor() + .command(command) + .readOutput(true) + .redirectError(Slf4jStream.of(openSSLLogger).asError()); + } + } } diff --git a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java index a9e37c4..ea0ada0 100644 --- a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java +++ b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java @@ -7,11 +7,20 @@ import de.mlessmann.certassist.repositories.CertificateRepository; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.StreamSupport; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class CertificateCreationService { @@ -19,12 +28,18 @@ public class CertificateCreationService { private final CertificateRepository certificateRepository; private final OpenSSLService openSSLService; private final PassphraseService passphraseService; + private final Pattern CERT_START_PATTERN = Pattern.compile("-+BEGIN CERTIFICATE-+"); + private final Pattern CERT_END_PATTERN = Pattern.compile("-+END CERTIFICATE-+"); public Certificate createCertificate(final CertificateInfo certificateInfo) { final Certificate certificate = createEntityFromRequest(certificateInfo); try (OpenSSLCertificateResult certificateCreatorResult = openSSLService.createCertificate(certificateInfo);) { - certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.certificateKeyPath())); + Path keyPath = certificateCreatorResult.certificateKeyPath(); + certificate.setFingerprint(certificateCreatorResult.fingerprint()); + if (keyPath != null) { + certificate.setPrivateKey(Files.readAllBytes(keyPath)); + } certificate.setCert(Files.readAllBytes(certificateCreatorResult.certificatePath())); } catch (CommandLineOperationException | IOException e) { throw new IllegalStateException("Failed to create certificate!", e); @@ -59,15 +74,23 @@ public class CertificateCreationService { return certificate; } - public Certificate importCertificate(Path certificate, Path keyFile, String passphrase) { + @NonNull + public Certificate importCertificate( + @NonNull Path certificate, + @Nullable Path keyFile, + @Nullable String keyPassphrase + ) { try { String fingerprint = openSSLService.getCertificateFingerprint(certificate); var generatedRequest = openSSLService.getCertificateInfo(certificate); Certificate entity = createEntityFromRequest(generatedRequest); + entity.setFingerprint(fingerprint); entity.setCert(Files.readAllBytes(certificate)); - entity.setPrivateKey(Files.readAllBytes(keyFile)); - if (StringUtils.isNotBlank(passphrase)) { - passphraseService.storePassphrase("cert:" + fingerprint, passphrase); + if (keyFile != null) { + entity.setPrivateKey(Files.readAllBytes(keyFile)); + } + if (StringUtils.isNotBlank(keyPassphrase)) { + passphraseService.storePassphrase("cert:" + fingerprint, keyPassphrase); } return certificateRepository.save(entity); } catch (CommandLineOperationException | IOException e) { @@ -75,6 +98,36 @@ public class CertificateCreationService { } } + public List importCertificateTrustBundle(@NonNull Path bundleFile) { + try { + Map certsInBundle = new HashMap<>(); + String pemContent = Files.readString(bundleFile); + Matcher beginMatcher = CERT_START_PATTERN.matcher(pemContent); + + while (beginMatcher.find()) { + int startIdx = beginMatcher.start(); + Matcher endMatcher = CERT_END_PATTERN.matcher(pemContent); + if (!endMatcher.find(startIdx)) { + throw new IllegalStateException("Certificate has a startIdx but not an end??"); + } + int endIdx = endMatcher.end(); + String singleCert = pemContent.substring(startIdx, endIdx); + String fingerprint = openSSLService.getCertificateFingerprint(singleCert); + var generatedRequest = openSSLService.getCertificateInfo(singleCert); + Certificate entity = createEntityFromRequest(generatedRequest); + entity.setFingerprint(fingerprint); + entity.setCert(singleCert.getBytes()); + certsInBundle.put(fingerprint, entity); + log.debug("Found certificate in bundle at {} to {}: {}", startIdx, endIdx, fingerprint); + } + + var saveResult = certificateRepository.saveAll(certsInBundle.values()); + return StreamSupport.stream(saveResult.spliterator(), false).toList(); + } catch (CommandLineOperationException | IOException e) { + throw new RuntimeException("Unable to import certificate", e); + } + } + private CertificateType mapCertificateRequestType(CertificateInfo.RequestType requestType) { return switch (requestType) { case ROOT_AUTHORITY -> CertificateType.ROOT_CA; diff --git a/src/main/java/de/mlessmann/certassist/service/CertificateProviderImpl.java b/src/main/java/de/mlessmann/certassist/service/CertificateProviderImpl.java index e6d5cb7..244dfef 100644 --- a/src/main/java/de/mlessmann/certassist/service/CertificateProviderImpl.java +++ b/src/main/java/de/mlessmann/certassist/service/CertificateProviderImpl.java @@ -1,5 +1,7 @@ package de.mlessmann.certassist.service; +import static java.util.Objects.requireNonNull; + import de.mlessmann.certassist.Constants; import de.mlessmann.certassist.DeleteRecursiveFileVisitor; import de.mlessmann.certassist.models.Certificate; @@ -29,7 +31,14 @@ public class CertificateProviderImpl implements CertificateProvider { @Override public CertificateUsage requestCertificateUsage(String fingerprint) { - Certificate certFromDB = certificateRepository.findByFingerprintIs(fingerprint); + requireNonNull(fingerprint, "Fingerprint must be provided."); + Certificate certFromDB; + try { + certFromDB = certificateRepository.findByFingerprintIs(fingerprint); + } catch (RuntimeException e) { + log.error("Failed to retrieve certificate from database by fingerprint: {}", fingerprint); + throw e; + } if (certFromDB == null) { throw new IllegalArgumentException("Unknown fingerprint"); } diff --git a/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java b/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java index 38f00f0..c8322ec 100644 --- a/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java +++ b/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java @@ -2,11 +2,18 @@ package de.mlessmann.certassist; import static org.assertj.core.api.Assertions.assertThat; +import de.mlessmann.certassist.keystore.KeyStoreManager; +import de.mlessmann.certassist.models.Certificate; import de.mlessmann.certassist.models.CertificateInfo; import de.mlessmann.certassist.models.CertificateInfoExtension; import de.mlessmann.certassist.models.CertificateInfoSubject; +import de.mlessmann.certassist.openssl.CertificateProvider; +import de.mlessmann.certassist.openssl.CertificateUsage; +import de.mlessmann.certassist.repositories.CertificateRepository; import de.mlessmann.certassist.service.CertificateCreationService; import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -14,8 +21,24 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest public class CertificateServiceTest { + public static final Path LETSENCRYPT_CERT_PATH = Path.of("src/test/resources/letsencrypt"); + @Autowired - private CertificateCreationService certificateService; + CertificateRepository certificateRepository; + + @Autowired + CertificateCreationService certificateService; + + @Autowired + KeyStoreManager keyStoreManager; + + @Autowired + CertificateProvider certificateProvider; + + @BeforeEach + void setUp() { + certificateRepository.deleteAll(); + } @Test void testCanCreateCertificate() { @@ -49,4 +72,30 @@ public class CertificateServiceTest { assertThat(importedCert).isNotNull(); assertThat(importedCert.getId()).isGreaterThan("0"); } + + @Test + void testCanImportTrustCertificate() { + Path cert = LETSENCRYPT_CERT_PATH.resolve("2024-11-isrgrootx1.pem"); + var importedCert = certificateService.importCertificate(cert, null, null); + assertThat(importedCert).isNotNull(); + assertThat(importedCert.getId()).isGreaterThan("0"); + } + + @Test + void testCanImportTrustBundles() throws Exception { + Path cert = LETSENCRYPT_CERT_PATH.resolve("2024-11-letsencrypt.bundle.pem"); + List importedCerts = certificateService.importCertificateTrustBundle(cert); + List tmpUsableCerts = importedCerts + .stream() + .map(Certificate::getFingerprint) + .map(certificateProvider::requestCertificateUsage) + .toList(); + + try (var store = keyStoreManager.createTruststore("changeit", tmpUsableCerts)) { + assertThat(store).isNotNull(); + assertThat(store.truststorePath()).isRegularFile(); + } finally { + tmpUsableCerts.forEach(CertificateUsage::close); + } + } } diff --git a/src/test/java/de/mlessmann/certassist/TestKeystoreCreation.java b/src/test/java/de/mlessmann/certassist/TestKeystoreCreation.java index d8b657a..f480b71 100644 --- a/src/test/java/de/mlessmann/certassist/TestKeystoreCreation.java +++ b/src/test/java/de/mlessmann/certassist/TestKeystoreCreation.java @@ -3,7 +3,6 @@ package de.mlessmann.certassist; import static org.assertj.core.api.Assertions.assertThat; import de.mlessmann.certassist.keystore.KeyStoreManager; -import de.mlessmann.certassist.keystore.TruststoreManager; import de.mlessmann.certassist.openssl.CertificateProvider; import de.mlessmann.certassist.openssl.CertificateUsage; import de.mlessmann.certassist.openssl.InMemoryCertificatePasswordProvider; @@ -53,13 +52,12 @@ public class TestKeystoreCreation { var certificateProvider = Mockito.mock(CertificateProvider.class); var opensslCertCreator = new OpenSSLService(new ExecutableResolver(), passwordProvider, certificateProvider); - var truststoreManager = new TruststoreManager(); var keyStoreManager = new KeyStoreManager(opensslCertCreator, passwordProvider); AtomicBoolean serverAccepted = new AtomicBoolean(false); AtomicBoolean clientAccepted = new AtomicBoolean(false); try ( - var tmpTruststore = truststoreManager.createTruststore(STORE_PASSPHRASE, dummyCert); + var tmpTruststore = keyStoreManager.createTruststore(STORE_PASSPHRASE, dummyCert); var tmpKeyStore = keyStoreManager.createKeyStore(STORE_PASSPHRASE, dummyCert) ) { KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance( @@ -69,7 +67,6 @@ public class TestKeystoreCreation { SSLContext tlsSrvContext = SSLContext.getInstance("TLS"); tlsSrvContext.init(keyManagerFactory.getKeyManagers(), null, TEST_RANDOM); - int serverPort = 1024 + TEST_RANDOM.nextInt(22_000); ServerSocket serverSocket = tlsSrvContext.getServerSocketFactory().createServerSocket(0); var serverThread = Thread.startVirtualThread(() -> { diff --git a/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java b/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java index 1afdc3f..4d95f48 100644 --- a/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java +++ b/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java @@ -53,6 +53,7 @@ class CertificateRepositoryTest { @Transactional void findCertificateWithExtension() { final Certificate certificate = getCertificate(); + certificate.setFingerprint("test-fingerprint"); CertificateExtension extension = new CertificateExtension(); extension.setValue("test-ext-value"); certificate.setCertificateExtension(List.of(extension)); diff --git a/src/test/resources/letsencrypt/2024-11-isrg-root-x2.pem b/src/test/resources/letsencrypt/2024-11-isrg-root-x2.pem new file mode 100644 index 0000000..7d903ed --- /dev/null +++ b/src/test/resources/letsencrypt/2024-11-isrg-root-x2.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 +MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT +ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW ++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 +ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW +tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 +/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE----- diff --git a/src/test/resources/letsencrypt/2024-11-isrgrootx1.pem b/src/test/resources/letsencrypt/2024-11-isrgrootx1.pem new file mode 100644 index 0000000..b85c803 --- /dev/null +++ b/src/test/resources/letsencrypt/2024-11-isrgrootx1.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/src/test/resources/letsencrypt/2024-11-letsencrypt.bundle.pem b/src/test/resources/letsencrypt/2024-11-letsencrypt.bundle.pem new file mode 100644 index 0000000..0495ae0 --- /dev/null +++ b/src/test/resources/letsencrypt/2024-11-letsencrypt.bundle.pem @@ -0,0 +1,45 @@ +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 +MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT +ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW ++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 +ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW +tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 +/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE-----