diff --git a/.gitattributes b/.gitattributes index 8af972c..342dd57 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /gradlew text eol=lf *.bat text eol=crlf *.jar binary +*.java text eol=lf \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 68ae96d..5e3766a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,8 +29,10 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-autoconfigure") + implementation("org.zeroturnaround:zt-exec:1.12") + implementation("org.apache.commons:commons-lang3:3.17.0") + implementation("org.springframework.boot:spring-boot-autoconfigure") implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.xerial:sqlite-jdbc") implementation("org.springframework.boot:spring-boot-starter-data-jpa") @@ -53,4 +55,5 @@ dependencies { tasks.withType { useJUnitPlatform() + testLogging.showStandardStreams = true } diff --git a/src/main/java/de/mlessmann/certassist/DeleteRecursiveFileVisitor.java b/src/main/java/de/mlessmann/certassist/DeleteRecursiveFileVisitor.java new file mode 100644 index 0000000..8310eca --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/DeleteRecursiveFileVisitor.java @@ -0,0 +1,46 @@ +package de.mlessmann.certassist; + +import static org.slf4j.LoggerFactory.getLogger; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import org.slf4j.Logger; +import org.springframework.lang.NonNull; + +public class DeleteRecursiveFileVisitor implements FileVisitor { + + private static final Logger LOGGER = getLogger(DeleteRecursiveFileVisitor.class); + + @NonNull + @Override + public FileVisitResult preVisitDirectory(Path dir, @NonNull BasicFileAttributes attrs) throws IOException { + return FileVisitResult.CONTINUE; + } + + @NonNull + @Override + public FileVisitResult visitFile(Path file, @NonNull BasicFileAttributes attrs) throws IOException { + LOGGER.trace("Deleting file {}", file); + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @NonNull + @Override + public FileVisitResult visitFileFailed(Path file, @NonNull IOException exc) throws IOException { + LOGGER.error("Could not delete file {}", file, exc); + throw exc; + } + + @NonNull + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + LOGGER.trace("Deleting directory {}", dir); + Files.delete(dir); + return FileVisitResult.CONTINUE; + } +} diff --git a/src/main/java/de/mlessmann/certassist/ExecutableResolver.java b/src/main/java/de/mlessmann/certassist/ExecutableResolver.java new file mode 100644 index 0000000..8ae0cd0 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/ExecutableResolver.java @@ -0,0 +1,68 @@ +package de.mlessmann.certassist; + +import static org.slf4j.LoggerFactory.getLogger; + +import de.mlessmann.certassist.except.UnresolvableCLIDependency; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.lang3.SystemUtils; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ExecutableResolver { + + private static final Logger LOGGER = getLogger(ExecutableResolver.class); + + @Value("${openssl.path:#{null}}") + private String opensslPath; + + public String getOpenSSLPath() throws UnresolvableCLIDependency { + if (opensslPath == null) { + LOGGER.atDebug().log("No openssl path configured, falling back to resolving by shell."); + var optSSLPath = searchCommandFromPath("openssl"); + opensslPath = optSSLPath.orElseThrow(() -> new UnresolvableCLIDependency("openssl", "openssl.path")); + } + + Path configuredPath = new File(opensslPath).toPath(); + if (!Files.isRegularFile(configuredPath)) { + throw new UnresolvableCLIDependency("openssl", "openssl.path"); + } + + return opensslPath; + } + + private Optional searchCommandFromPath(String executableName) { + String envPath = System.getenv("PATH"); + Objects.requireNonNull(envPath, "Environment variable 'PATH' is not set?!"); + String[] pathEntries = envPath.split(File.pathSeparator); + + for (String pathEntry : pathEntries) { + for (String fileExtension : getAllowedExtensions()) { + Path executablePath = Path.of(pathEntry, executableName + fileExtension); + if (Files.isRegularFile(executablePath) && Files.isExecutable(executablePath)) { + return Optional.of(executablePath.toString()); + } + } + } + + LOGGER.error( + "Could not find executable '{}' in PATH. Make sure that it exists on the of the directory and is executable.", + executableName + ); + return Optional.empty(); + } + + public List getAllowedExtensions() { + if (SystemUtils.IS_OS_WINDOWS) { + return List.of(".exe", ".bat", ".cmd"); + } else { + return List.of("", ".sh"); + } + } +} diff --git a/src/main/java/de/mlessmann/certassist/except/CommandLineOperationException.java b/src/main/java/de/mlessmann/certassist/except/CommandLineOperationException.java new file mode 100644 index 0000000..10ca729 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/except/CommandLineOperationException.java @@ -0,0 +1,16 @@ +package de.mlessmann.certassist.except; + +public class CommandLineOperationException extends Exception { + + public CommandLineOperationException(String message) { + super(message); + } + + public CommandLineOperationException(String message, Throwable cause) { + super(message, cause); + } + + public CommandLineOperationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/de/mlessmann/certassist/except/UnresolvableCLIDependency.java b/src/main/java/de/mlessmann/certassist/except/UnresolvableCLIDependency.java new file mode 100644 index 0000000..08d106d --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/except/UnresolvableCLIDependency.java @@ -0,0 +1,21 @@ +package de.mlessmann.certassist.except; + +import lombok.Getter; + +@Getter +public class UnresolvableCLIDependency extends Exception { + + private final String executableName; + private final String propertyName; + + public UnresolvableCLIDependency(String executableName, String propertyName) { + super( + "Could not resolve executable for '%s'. (Use property '%s' to point the application directly to the executable.)".formatted( + executableName, + propertyName + ) + ); + this.executableName = executableName; + this.propertyName = propertyName; + } +} diff --git a/src/main/java/de/mlessmann/certassist/openssl/CertificateRequest.java b/src/main/java/de/mlessmann/certassist/openssl/CertificateRequest.java new file mode 100644 index 0000000..4d6ab9d --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/CertificateRequest.java @@ -0,0 +1,47 @@ +package de.mlessmann.certassist.openssl; + +import java.util.UUID; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CertificateRequest { + + @Builder.Default + private String oid = UUID.randomUUID().toString(); + + private RequestType type; + private String commonName; + private String trustingAuthority; + + @Builder.Default + private int requestedKeyLength = 4096; + + @Builder.Default + private int requestedValidityDays = 365; + + private CertificateSubject subject; + private CertificateRequestExtension extension; + + public enum RequestType { + ROOT_AUTHORITY, + STANDALONE_CERTIFICATE, + NORMAL_CERTIFICATE, + } + + public static class CertificateRequestBuilder { + + public CertificateRequestBuilder subject(CertificateSubject.CertificateSubjectBuilder builder) { + this.subject = builder.build(); + return this; + } + + public CertificateRequestBuilder extension( + CertificateRequestExtension.CertificateRequestExtensionBuilder builder + ) { + this.extension = builder.build(); + return this; + } + } +} diff --git a/src/main/java/de/mlessmann/certassist/openssl/CertificateRequestExtension.java b/src/main/java/de/mlessmann/certassist/openssl/CertificateRequestExtension.java new file mode 100644 index 0000000..4350717 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/CertificateRequestExtension.java @@ -0,0 +1,20 @@ +package de.mlessmann.certassist.openssl; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CertificateRequestExtension { + + private List alternativeNames; + + public static class CertificateRequestExtensionBuilder { + + public CertificateRequestExtensionBuilder alternativeNames(String... altNames) { + this.alternativeNames = List.of(altNames); + return this; + } + } +} diff --git a/src/main/java/de/mlessmann/certassist/openssl/CertificateSubject.java b/src/main/java/de/mlessmann/certassist/openssl/CertificateSubject.java new file mode 100644 index 0000000..5884d4a --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/CertificateSubject.java @@ -0,0 +1,16 @@ +package de.mlessmann.certassist.openssl; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CertificateSubject { + + private String emailAddress; + private String organization; + private String organizationalUnit; + private String country; + private String state; + private String locality; +} diff --git a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateCreator.java b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateCreator.java new file mode 100644 index 0000000..1649335 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateCreator.java @@ -0,0 +1,224 @@ +package de.mlessmann.certassist.openssl; + +import static org.slf4j.LoggerFactory.getLogger; + +import de.mlessmann.certassist.ExecutableResolver; +import de.mlessmann.certassist.except.CommandLineOperationException; +import de.mlessmann.certassist.except.UnresolvableCLIDependency; +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.concurrent.ExecutionException; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.StartedProcess; +import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; + +@Service +public class OpenSSLCertificateCreator { + + public static final String OPENSSL_CERT_SUBJECT_TEMPLATE = + "/C=ISO-COUNTRY/ST=STATE/L=LOCALITY/O=ORGANIZATION/CN=COMMON-NAME"; + private static final Logger LOGGER = getLogger(OpenSSLCertificateCreator.class); + private static final String CSR_EXT_TEMPLATE = + """ + authorityKeyIdentifier=keyid,issuer + basicConstraints=CA:FALSE + keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment + subjectAltName = @alt_names + + [alt_names] + """; + + private final ExecutableResolver executableResolver; + + @Autowired + public OpenSSLCertificateCreator(ExecutableResolver executableResolver) { + this.executableResolver = executableResolver; + } + + private static String buildSubjectArg(CertificateRequest request) { + String certSubject = OPENSSL_CERT_SUBJECT_TEMPLATE + .replace("ISO-COUNTRY", request.getSubject().getCountry()) + .replace("STATE", request.getSubject().getState()) + .replace("LOCALITY", request.getSubject().getLocality()) + .replace("ORGANIZATION", request.getSubject().getOrganization()) + .replace("COMMON-NAME", request.getCommonName()); + + if (StringUtils.isNotBlank(request.getSubject().getOrganizationalUnit())) { + certSubject += "/OU=" + request.getSubject().getOrganizationalUnit(); + } + + if (StringUtils.isNotBlank(request.getSubject().getEmailAddress())) { + certSubject += "/emailAddress=" + request.getSubject().getEmailAddress(); + } + return certSubject; + } + + @Nullable + public OpenSSLCertificateResult createCertificate(CertificateRequest request) + throws CommandLineOperationException, InterruptedException { + Path tmpDir; + try { + tmpDir = Files.createTempDirectory("certassist"); + } catch (IOException e) { + throw new CommandLineOperationException("Could not create temporary directory for certificate creation", e); + } + + Path keyFile = createKeyfile(request, tmpDir.resolve("root.key")); + Path rootCert = createCertificate(request, keyFile, tmpDir.resolve("root.crt")); + + Path childKey = createKeyfile(request, tmpDir.resolve("child.key")); + Path unsignedCert = createCertificate(request, childKey, tmpDir.resolve("child.csr")); + Path signedCert = signCertificate(request, rootCert, keyFile, unsignedCert); + return new OpenSSLCertificateResult(tmpDir); + } + + private Path createKeyfile(CertificateRequest request, Path outFile) + throws CommandLineOperationException, InterruptedException { + Path keyFile = outFile.toAbsolutePath(); + LOGGER.atDebug().log("Writing new certificate key to {}", keyFile); + + try { + StartedProcess keygenProc = new ProcessExecutor() + .command( + resolveOpenSSL(), + "genrsa", + "-out", + keyFile.toString(), + "-passout", + "env:KEY_PASS", + Integer.toString(request.getRequestedKeyLength()) + ) + .environment("KEY_PASS", request.getOid()) + .redirectOutput(Slf4jStream.ofCaller().asDebug()) + .redirectError(Slf4jStream.ofCaller().asError()) + .start(); + keygenProc.getFuture().get(); + } catch (IOException e) { + throw new CommandLineOperationException("Failure running OpenSSL keygen command.", e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + return keyFile; + } + + private Path createCertificate(CertificateRequest request, Path keyFile, Path outFile) + throws CommandLineOperationException, InterruptedException { + LOGGER.atDebug().log("Writing new certificate file {}", outFile); + + String certSubject = buildSubjectArg(request); + try { + StartedProcess certGenProc = new ProcessExecutor() + .command( + resolveOpenSSL(), + "req", + "-new", + "-passin", + "env:KEY_PASS", + "-key", + keyFile.toString(), + "-sha256", + "-days", + Integer.toString(request.getRequestedValidityDays()), + "-out", + outFile.toString(), + "-passout", + "env:KEY_PASS", + "-utf8", + "-subj", + certSubject + ) + .environment("KEY_PASS", request.getOid()) + .redirectOutput(Slf4jStream.ofCaller().asDebug()) + .redirectError(Slf4jStream.ofCaller().asError()) + .start(); + certGenProc.getFuture().get(); + } catch (IOException e) { + throw new CommandLineOperationException("Failure running OpenSSL req command.", e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + return outFile; + } + + private Path signCertificate(CertificateRequest request, Path caCert, Path caKey, Path csrFile) + throws CommandLineOperationException, InterruptedException { + Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt")); + LOGGER.atDebug().log("Writing new signed certificate file {}", outFile); + Path extFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".ext")); + + try { + String extContent = CSR_EXT_TEMPLATE; + List altNames = Optional + .ofNullable(request.getExtension()) + .map(CertificateRequestExtension::getAlternativeNames) + .orElse(List.of()); + if (!altNames.isEmpty()) { + String altNamesContent = String.join("\n", altNames); + extContent = extContent.replaceAll("\\[alt_names]\n?, ", "[alt_names]\n" + altNamesContent); + } else { + extContent = extContent.replaceAll("\\s*subjectAltName\\s+=\\s+@alt_names\n?", ""); + extContent = extContent.replaceAll("\\[alt_names]\n?, ", ""); + } + + LOGGER.debug("Writing extension file content: \n {}", extContent); + Files.writeString( + extFile, + extContent, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + + try { + StartedProcess certGenProc = new ProcessExecutor() + .command( + resolveOpenSSL(), + "x509", + "-req", + "-days", + Integer.toString(request.getRequestedValidityDays()), + "-in", + csrFile.toString(), + "-CA", + caCert.toString(), + "-CAkey", + caKey.toString(), + "-CAcreateserial", + "-out", + outFile.toString(), + "-extfile", + extFile.toString() + ) + .redirectOutput(Slf4jStream.ofCaller().asDebug()) + .redirectError(Slf4jStream.ofCaller().asError()) + .start(); + certGenProc.getFuture().get(); + } catch (IOException e) { + throw new CommandLineOperationException("Failure running OpenSSL x509 command.", e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + return outFile; + } + + private String resolveOpenSSL() throws CommandLineOperationException { + try { + return executableResolver.getOpenSSLPath(); + } catch (UnresolvableCLIDependency e) { + throw new CommandLineOperationException(e); + } + } +} diff --git a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateResult.java b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateResult.java new file mode 100644 index 0000000..82d4860 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLCertificateResult.java @@ -0,0 +1,28 @@ +package de.mlessmann.certassist.openssl; + +import static org.slf4j.LoggerFactory.getLogger; + +import de.mlessmann.certassist.DeleteRecursiveFileVisitor; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import org.slf4j.Logger; + +public class OpenSSLCertificateResult implements AutoCloseable { + + private static final Logger LOGGER = getLogger(OpenSSLCertificateResult.class); + + private final Path tmpDir; + + OpenSSLCertificateResult(Path tmpDir) { + this.tmpDir = tmpDir; + } + + @Override + public void close() throws IOException { + LOGGER.info("Cleaning up temporary output directory {}", tmpDir); + Files.walkFileTree(tmpDir, Set.of(), Integer.MAX_VALUE, new DeleteRecursiveFileVisitor()); + Files.deleteIfExists(tmpDir); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1b73bda..177ac97 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,4 +8,8 @@ password=admin spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect #TODO: Use flyway for db setup hibernate.hbm2ddl.auto=create-drop -hibernate.show_sql=true \ No newline at end of file +hibernate.show_sql=true + +# Logging +logging.level.root=INFO +logging.level.de.mlessmann.certassist=DEBUG diff --git a/src/test/java/de/mlessmann/certassist/TestOpenSSLCertificateCreator.java b/src/test/java/de/mlessmann/certassist/TestOpenSSLCertificateCreator.java new file mode 100644 index 0000000..ee6bf0d --- /dev/null +++ b/src/test/java/de/mlessmann/certassist/TestOpenSSLCertificateCreator.java @@ -0,0 +1,35 @@ +package de.mlessmann.certassist; + +import de.mlessmann.certassist.openssl.CertificateRequest; +import de.mlessmann.certassist.openssl.CertificateRequest.RequestType; +import de.mlessmann.certassist.openssl.CertificateRequestExtension; +import de.mlessmann.certassist.openssl.CertificateSubject; +import de.mlessmann.certassist.openssl.OpenSSLCertificateCreator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestOpenSSLCertificateCreator { + + private OpenSSLCertificateCreator openSSLCertificateCreator; + + @BeforeEach + void setUp() { + ExecutableResolver executableResolver = new ExecutableResolver(); + openSSLCertificateCreator = new OpenSSLCertificateCreator(executableResolver); + } + + @Test + void testCertificateCreation() throws Exception { + CertificateRequest certRequest = CertificateRequest + .builder() + .commonName("test.home") + .type(RequestType.STANDALONE_CERTIFICATE) + .subject(CertificateSubject.builder().country("DE").state("SH").locality("HH").organization("Crazy-Cats")) + .extension(CertificateRequestExtension.builder().alternativeNames("test2.home", "test3.home")) + .build(); + + try (var cert = openSSLCertificateCreator.createCertificate(certRequest)) { + System.out.println("Certificate created: " + cert); + } + } +}