From c44d842226c4a22004b34dd76e738edfaa4796c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Le=C3=9Fmann=20=28=40MarkL4YG=29?= Date: Sun, 24 Nov 2024 12:44:06 +0100 Subject: [PATCH] feat: Implement import of CA bundles to keystores --- .../certassist/keystore/KeyStoreManager.java | 26 ++++++++-- .../certassist/models/Certificate.java | 2 + .../certassist/openssl/OpenSSLService.java | 47 +++++++++++++++++++ .../service/CertificateCreationService.java | 41 ++++++++++++++++ .../service/CertificateProviderImpl.java | 11 ++++- .../certassist/CertificateServiceTest.java | 41 +++++++++++++++- .../CertificateRepositoryTest.java | 1 + .../2024-11-letsencrypt.bundle.pem | 45 ++++++++++++++++++ 8 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 src/test/resources/letsencrypt/2024-11-letsencrypt.bundle.pem diff --git a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java index e9d58f4..23f446e 100644 --- a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java +++ b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java @@ -15,7 +15,9 @@ 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; @@ -30,7 +32,7 @@ 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<>(); @@ -57,7 +59,15 @@ 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(); @@ -90,9 +100,15 @@ public class KeyStoreManager { public void loadPublicKeyIntoTrust(KeyStore keyStore, CertificateUsage cert) throws KeyStoreException { try (var inputStream = Files.newInputStream(cert.certificatePath())) { - var parsedCert = getX509Factory().generateCertificate(inputStream); + 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 certificate??"); + throw new IllegalStateException("CertificateUsage did not parse to X509 format??"); } keyStore.setCertificateEntry("TRUST;%s".formatted(cert.fingerprint()), x509cert); } catch (IOException e) { @@ -105,8 +121,8 @@ public class KeyStoreManager { @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); } diff --git a/src/main/java/de/mlessmann/certassist/models/Certificate.java b/src/main/java/de/mlessmann/certassist/models/Certificate.java index ffcb4c4..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 { @@ -57,5 +58,6 @@ public class Certificate { @Column private byte[] fullchain; + @Column(nullable = false) private String fingerprint; } diff --git a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java index a1416f9..1c09421 100644 --- a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.security.cert.X509Certificate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,6 +31,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -535,6 +537,51 @@ public class OpenSSLService { } } + @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."); + Path tmpFile = Files.createTempFile(CERTASSIST_TMP_PREFIX, ".pem"); + try { + Files.writeString(tmpFile, pemContent); + return getCertificateFingerprint(tmpFile); + } finally { + try { + Files.deleteIfExists(tmpFile); + } catch (IOException e) { + log.warn("Unable to delete temporary file, adding to shutdown hook. {}", tmpFile); + tmpFile.toFile().deleteOnExit(); + } + } + } + + @NonNull + @SneakyThrows + public CertificateInfo getCertificateInfo(String pemContent) { + Path tmpFile = Files.createTempFile(CERTASSIST_TMP_PREFIX, ".pem"); + try { + Files.writeString(tmpFile, pemContent); + return getCertificateInfo(tmpFile); + } finally { + try { + Files.deleteIfExists(tmpFile); + } catch (IOException e) { + log.warn("Unable to delete temporary file, adding to shutdown hook. {}", tmpFile); + tmpFile.toFile().deleteOnExit(); + } + } + } + @NonNull public CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { requireNonNull(path, "Certificate file must be provided to read the info."); diff --git a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java index 149533d..ea0ada0 100644 --- a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java +++ b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java @@ -7,13 +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 { @@ -21,12 +28,15 @@ 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);) { Path keyPath = certificateCreatorResult.certificateKeyPath(); + certificate.setFingerprint(certificateCreatorResult.fingerprint()); if (keyPath != null) { certificate.setPrivateKey(Files.readAllBytes(keyPath)); } @@ -74,6 +84,7 @@ public class CertificateCreationService { String fingerprint = openSSLService.getCertificateFingerprint(certificate); var generatedRequest = openSSLService.getCertificateInfo(certificate); Certificate entity = createEntityFromRequest(generatedRequest); + entity.setFingerprint(fingerprint); entity.setCert(Files.readAllBytes(certificate)); if (keyFile != null) { entity.setPrivateKey(Files.readAllBytes(keyFile)); @@ -87,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 b48ac2d..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; @@ -17,7 +24,21 @@ 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() { @@ -59,4 +80,22 @@ public class CertificateServiceTest { 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/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-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-----