diff --git a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java index 251c1cf..e9d58f4 100644 --- a/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java +++ b/src/main/java/de/mlessmann/certassist/keystore/KeyStoreManager.java @@ -16,6 +16,7 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; +import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; @@ -79,7 +80,10 @@ public class KeyStoreManager { public void loadPrivateKeyIntoStore(KeyStore keyStore, CertificateUsage cert) throws KeyStoreException { String passphrase = passwordProvider.getPasswordFor(cert.fingerprint()); - var pKey = loadPrivateKey(cert.certificateKeyPath(), passphrase); + 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); } diff --git a/src/main/java/de/mlessmann/certassist/models/Certificate.java b/src/main/java/de/mlessmann/certassist/models/Certificate.java index a92484a..ffcb4c4 100644 --- a/src/main/java/de/mlessmann/certassist/models/Certificate.java +++ b/src/main/java/de/mlessmann/certassist/models/Certificate.java @@ -50,7 +50,7 @@ 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 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..a1416f9 100644 --- a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java @@ -1,6 +1,7 @@ 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 org.slf4j.LoggerFactory.getLogger; @@ -33,6 +34,7 @@ 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; @@ -105,6 +107,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 +134,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 +166,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 +188,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 +201,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 +249,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 +281,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 +302,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; @@ -339,6 +371,8 @@ public class OpenSSLService { */ 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 +399,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 +434,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,7 +489,10 @@ public class OpenSSLService { return outFile; } - public String getCertificateFingerprint(Path certificate) throws CommandLineOperationException { + @NonNull + public String getCertificateFingerprint(@NonNull Path certificate) throws CommandLineOperationException { + requireNonNull(certificate, "Certificate must be provided to generate fingerprint."); + StartedProcess fingerprintProc = null; try { fingerprintProc = @@ -492,7 +535,10 @@ public class OpenSSLService { } } + @NonNull public CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { + requireNonNull(path, "Certificate file must be provided to read the info."); + StartedProcess infoProc = null; try { infoProc = @@ -534,9 +580,12 @@ public class OpenSSLService { return getCertificateInfo(output.lines().toArray(String[]::new)); } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { throw new RuntimeException(e); + } finally { + killIfActive(infoProc); } } + @NonNull private String resolveOpenSSL() throws CommandLineOperationException { try { String path = executableResolver.getOpenSSLPath(); @@ -621,7 +670,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 = diff --git a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java index a9e37c4..149533d 100644 --- a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java +++ b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java @@ -10,6 +10,8 @@ import java.nio.file.Path; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; @Service @@ -24,7 +26,10 @@ public class CertificateCreationService { final Certificate certificate = createEntityFromRequest(certificateInfo); try (OpenSSLCertificateResult certificateCreatorResult = openSSLService.createCertificate(certificateInfo);) { - certificate.setPrivateKey(Files.readAllBytes(certificateCreatorResult.certificateKeyPath())); + Path keyPath = certificateCreatorResult.certificateKeyPath(); + 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 +64,22 @@ 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.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) { diff --git a/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java b/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java index 38f00f0..b48ac2d 100644 --- a/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java +++ b/src/test/java/de/mlessmann/certassist/CertificateServiceTest.java @@ -14,6 +14,8 @@ 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; @@ -49,4 +51,12 @@ 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"); + } } 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-----