feat: Allow some OpenSSL commands to happen inline

- This reduces the number of disk reads/writes and allows for less potential IOExceptions
This commit is contained in:
Magnus Leßmann (@MarkL4YG) 2024-11-24 13:44:34 +01:00
parent 6b1c969ce6
commit 0cac57dd15

View file

@ -3,6 +3,7 @@ package de.mlessmann.certassist.openssl;
import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX; import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX;
import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.*; import static java.util.concurrent.TimeUnit.*;
import static lombok.AccessLevel.PRIVATE;
import static org.slf4j.LoggerFactory.getLogger; import static org.slf4j.LoggerFactory.getLogger;
import de.mlessmann.certassist.DeleteRecursiveFileVisitor; import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
@ -14,15 +15,14 @@ import de.mlessmann.certassist.models.CertificateInfoExtension;
import de.mlessmann.certassist.models.CertificateInfoSubject; import de.mlessmann.certassist.models.CertificateInfoSubject;
import de.mlessmann.certassist.models.CertificateInfoSubject.CertificateInfoSubjectBuilder; import de.mlessmann.certassist.models.CertificateInfoSubject.CertificateInfoSubjectBuilder;
import de.mlessmann.certassist.service.ExecutableResolver; import de.mlessmann.certassist.service.ExecutableResolver;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.List; import java.util.*;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -49,9 +50,9 @@ import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
@Slf4j @Slf4j
public class OpenSSLService { public class OpenSSLService {
private static final Logger openSSLLogger = getLogger("OpenSSL-Logger");
public static final String OPENSSL_CERT_SUBJECT_TEMPLATE = public static final String OPENSSL_CERT_SUBJECT_TEMPLATE =
"/C=ISO-COUNTRY/ST=STATE/L=LOCALITY/O=ORGANIZATION/CN=COMMON-NAME"; "/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 = private static final String CSR_EXT_TEMPLATE =
""" """
authorityKeyIdentifier=keyid,issuer authorityKeyIdentifier=keyid,issuer
@ -369,6 +370,7 @@ public class OpenSSLService {
/** /**
* Verifies a passphrase against a provided key. * Verifies a passphrase against a provided key.
*
* @implNote Due to the implementation of the OpenSSL cli, <em>any password</em> will be valid for unencrypted keys. (Check with {@link #isKeyEncrypted(Path).) * @implNote Due to the implementation of the OpenSSL cli, <em>any password</em> will be valid for unencrypted keys. (Check with {@link #isKeyEncrypted(Path).)
*/ */
public boolean verifyKeyPassphrase(@NonNull Path keyFile, @NonNull String passphrase) public boolean verifyKeyPassphrase(@NonNull Path keyFile, @NonNull String passphrase)
@ -495,48 +497,40 @@ public class OpenSSLService {
public String getCertificateFingerprint(@NonNull Path certificate) throws CommandLineOperationException { public String getCertificateFingerprint(@NonNull Path certificate) throws CommandLineOperationException {
requireNonNull(certificate, "Certificate must be provided to generate fingerprint."); requireNonNull(certificate, "Certificate must be provided to generate fingerprint.");
StartedProcess fingerprintProc = null;
try { try {
fingerprintProc = return getCertificateFingerprint(Files.readString(certificate));
new ProcessExecutor() } catch (IOException e) {
.command(resolveOpenSSL(), "x509", "-in", certificate.toString(), "-noout", "-fingerprint") throw new CommandLineOperationException("Certificate content could not be read.", e);
.readOutput(true)
.redirectError(Slf4jStream.of(openSSLLogger).asError())
.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);
} catch (IOException | ExecutionException | TimeoutException | InterruptedException e) {
throw new RuntimeException(e);
} finally {
killIfActive(fingerprintProc);
} }
} }
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 @NonNull
@SneakyThrows @SneakyThrows
public String getCertificateFingerPrint(X509Certificate jdkCert) { public String getCertificateFingerPrint(X509Certificate jdkCert) {
@ -551,71 +545,25 @@ public class OpenSSLService {
@SneakyThrows @SneakyThrows
public String getCertificateFingerprint(@NonNull String pemContent) throws CommandLineOperationException { public String getCertificateFingerprint(@NonNull String pemContent) throws CommandLineOperationException {
requireNonNull(pemContent, "Certificate PEM content must be provided to generate fingerprint from string."); requireNonNull(pemContent, "Certificate PEM content must be provided to generate fingerprint from string.");
Path tmpFile = Files.createTempFile(CERTASSIST_TMP_PREFIX, ".pem");
try { StartedProcess fingerprintProc = null;
Files.writeString(tmpFile, pemContent); try (var input = new ByteArrayInputStream(pemContent.getBytes())) {
return getCertificateFingerprint(tmpFile); fingerprintProc = Commands.fingerprintCommand(resolveOpenSSL()).redirectInput(input).start();
var fingerprintResult = fingerprintProc.getFuture().get(30, SECONDS);
return extractFingerprintFromOutput(fingerprintResult);
} catch (IOException | ExecutionException | TimeoutException | InterruptedException e) {
throw new RuntimeException(e);
} finally { } finally {
try { killIfActive(fingerprintProc);
Files.deleteIfExists(tmpFile);
} catch (IOException e) {
log.warn("Unable to delete temporary file, adding to shutdown hook. {}", tmpFile);
tmpFile.toFile().deleteOnExit();
}
} }
} }
@NonNull @NonNull
@SneakyThrows @SneakyThrows
public CertificateInfo getCertificateInfo(String pemContent) { 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.");
StartedProcess infoProc = null; StartedProcess infoProc = null;
try { try (var input = new ByteArrayInputStream(pemContent.getBytes())) {
infoProc = infoProc = Commands.infoCommand(resolveOpenSSL()).redirectInput(input).start();
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();
var infoResult = infoProc.getFuture().get(30, SECONDS); var infoResult = infoProc.getFuture().get(30, SECONDS);
String output = infoResult.getOutput().getUTF8(); String output = infoResult.getOutput().getUTF8();
if (infoResult.getExitValue() != 0) { if (infoResult.getExitValue() != 0) {
@ -632,6 +580,16 @@ public class OpenSSLService {
} }
} }
@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 @NonNull
private String resolveOpenSSL() throws CommandLineOperationException { private String resolveOpenSSL() throws CommandLineOperationException {
try { try {
@ -747,4 +705,48 @@ public class OpenSSLService {
killIfActive(keyReadProc); killIfActive(keyReadProc);
} }
} }
@NoArgsConstructor(access = PRIVATE)
private static class Commands {
private static ProcessExecutor infoCommand(String openSSL, String... additArgs) {
List<String> 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<String> 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());
}
}
} }