- Technically, CAs and intermediate CAs do not use "fullchain" certificates, but it is useful to us to include the entire certificate chain in the leaf certificate
391 lines
16 KiB
Java
391 lines
16 KiB
Java
package de.mlessmann.certassist.openssl;
|
|
|
|
import static de.mlessmann.certassist.Constants.CERTASSIST_TMP_PREFIX;
|
|
|
|
import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
|
|
import de.mlessmann.certassist.ExecutableResolver;
|
|
import de.mlessmann.certassist.except.CommandLineOperationException;
|
|
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
|
|
import de.mlessmann.certassist.openssl.CertificateRequest.RequestType;
|
|
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 java.util.concurrent.atomic.AtomicInteger;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
import java.util.stream.Collectors;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.apache.commons.lang3.StringUtils;
|
|
import org.springframework.lang.NonNull;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.util.CollectionUtils;
|
|
import org.zeroturnaround.exec.ProcessExecutor;
|
|
import org.zeroturnaround.exec.StartedProcess;
|
|
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
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 String CSR_EXT_TEMPLATE =
|
|
"""
|
|
authorityKeyIdentifier=keyid,issuer
|
|
basicConstraints=CA:FALSE
|
|
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
|
subjectAltName = @alt_names
|
|
|
|
[alt_names]
|
|
""";
|
|
private static final Pattern FINGERPRINT_EXTRACTOR = Pattern.compile(
|
|
"^(?<algo>[0-9a-zA-Z]+) (?i)Fingerprint(?-i)=(?<finger>[a-z:A-Z0-9]+)"
|
|
);
|
|
|
|
private final ExecutableResolver executableResolver;
|
|
private final CertificatePasswordProvider passwordProvider;
|
|
private final CertificateProvider certificateProvider;
|
|
|
|
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;
|
|
}
|
|
|
|
@NonNull
|
|
public OpenSSLCertificateResult createCertificate(CertificateRequest request)
|
|
throws CommandLineOperationException, InterruptedException {
|
|
Path tmpDir;
|
|
try {
|
|
tmpDir = Files.createTempDirectory(CERTASSIST_TMP_PREFIX);
|
|
} catch (IOException e) {
|
|
throw new CommandLineOperationException("Could not create temporary directory for certificate creation", e);
|
|
}
|
|
|
|
String certPassword = passwordProvider.generateNewPassword();
|
|
Path keyFile = createKeyfile(request, tmpDir.resolve("certificate.key"), certPassword);
|
|
if (
|
|
request.getType() == RequestType.ROOT_AUTHORITY || request.getType() == RequestType.STANDALONE_CERTIFICATE
|
|
) {
|
|
Path certificate = createCertificate(request, keyFile, tmpDir.resolve("certificate.crt"), certPassword);
|
|
String fingerprint = getCertificateFingerprint(certificate);
|
|
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
|
return new OpenSSLCertificateResult(tmpDir, certificate, keyFile, certificate, fingerprint);
|
|
}
|
|
|
|
try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) {
|
|
Path unsignedCert = createSigningRequest(request, keyFile, tmpDir.resolve("child.csr"), certPassword);
|
|
Path signedCert = signCertificate(
|
|
request,
|
|
certAuthority.certificatePath(),
|
|
certAuthority.certificateKeyPath(),
|
|
unsignedCert,
|
|
certPassword
|
|
);
|
|
String fingerprint = getCertificateFingerprint(signedCert);
|
|
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
|
|
|
Path fullchain = tmpDir.resolve("fullchain.pem");
|
|
try {
|
|
Path certAuthFullchain = Optional
|
|
.ofNullable(certAuthority.fullchainPath())
|
|
.orElse(certAuthority.certificatePath());
|
|
Files.write(fullchain, Files.readAllBytes(certAuthFullchain), StandardOpenOption.CREATE);
|
|
Files.write(fullchain, Files.readAllBytes(signedCert), StandardOpenOption.APPEND);
|
|
} catch (IOException e) {
|
|
throw new CommandLineOperationException("Failed to create fullchain file.", e);
|
|
}
|
|
|
|
return new OpenSSLCertificateResult(tmpDir, signedCert, keyFile, fullchain, fingerprint);
|
|
}
|
|
}
|
|
|
|
private Path createKeyfile(CertificateRequest request, Path outFile, String filePassword)
|
|
throws CommandLineOperationException, InterruptedException {
|
|
Path keyFile = outFile.toAbsolutePath();
|
|
log.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", filePassword)
|
|
.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, String certPassword)
|
|
throws CommandLineOperationException, InterruptedException {
|
|
log.atDebug().log("Writing new certificate file {}", outFile);
|
|
|
|
String certSubject = buildSubjectArg(request);
|
|
try {
|
|
StartedProcess certGenProc = new ProcessExecutor()
|
|
.command(
|
|
resolveOpenSSL(),
|
|
"req",
|
|
"-x509",
|
|
"-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", certPassword)
|
|
.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 createSigningRequest(CertificateRequest request, Path keyFile, Path outFile, String certPassword)
|
|
throws CommandLineOperationException, InterruptedException {
|
|
log.atDebug().log("Writing new certificate signing request file {}", outFile);
|
|
|
|
String certSubject = buildSubjectArg(request);
|
|
try {
|
|
StartedProcess certGenProc = new ProcessExecutor()
|
|
.command(
|
|
resolveOpenSSL(),
|
|
"req",
|
|
"-new",
|
|
"-passin",
|
|
"env:KEY_PASS",
|
|
"-key",
|
|
keyFile.toString(),
|
|
"-sha256",
|
|
"-out",
|
|
outFile.toString(),
|
|
"-passout",
|
|
"env:KEY_PASS",
|
|
"-utf8",
|
|
"-subj",
|
|
certSubject
|
|
)
|
|
.environment("KEY_PASS", certPassword)
|
|
.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;
|
|
}
|
|
|
|
public boolean verifyCertificate(@NonNull Path fullChainFile, @NonNull Path trustedCA)
|
|
throws CommandLineOperationException {
|
|
return verifyCertificate(fullChainFile, List.of(trustedCA));
|
|
}
|
|
|
|
public boolean verifyCertificate(@NonNull Path fullChainFile, @NonNull List<Path> trustedCAs)
|
|
throws CommandLineOperationException {
|
|
if (CollectionUtils.isEmpty(trustedCAs)) {
|
|
throw new IllegalArgumentException(
|
|
"At least one trusted CA certificate must be provided to run the verification command."
|
|
);
|
|
}
|
|
|
|
Path tmpDir = null;
|
|
try {
|
|
Path tempTrustedBundle;
|
|
if (trustedCAs.size() == 1) {
|
|
tempTrustedBundle = trustedCAs.getFirst();
|
|
} else {
|
|
tmpDir = Files.createTempDirectory(CERTASSIST_TMP_PREFIX);
|
|
tempTrustedBundle = tmpDir.resolve("trusted_bundle.pem");
|
|
for (Path ca : trustedCAs) {
|
|
Files.writeString(
|
|
tempTrustedBundle,
|
|
Files.readString(ca),
|
|
StandardCharsets.UTF_8,
|
|
StandardOpenOption.CREATE,
|
|
StandardOpenOption.APPEND
|
|
);
|
|
}
|
|
}
|
|
|
|
StartedProcess verifyCommand = new ProcessExecutor()
|
|
.command(resolveOpenSSL(), "verify", "-CAfile", tempTrustedBundle.toString(), fullChainFile.toString())
|
|
.redirectOutput(Slf4jStream.ofCaller().asError())
|
|
.redirectError(Slf4jStream.ofCaller().asError())
|
|
.start();
|
|
var verifyResult = verifyCommand.getFuture().get();
|
|
return verifyResult.getExitValue() == 0;
|
|
} catch (IOException | InterruptedException | ExecutionException e) {
|
|
throw new RuntimeException(e);
|
|
} finally {
|
|
if (tmpDir != null) {
|
|
try {
|
|
Files.walkFileTree(tmpDir, new DeleteRecursiveFileVisitor());
|
|
Files.deleteIfExists(tmpDir);
|
|
} catch (IOException e) {
|
|
log.error("Failed to clean up temporary verification directory: {}", tmpDir, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private Path signCertificate(
|
|
CertificateRequest request,
|
|
Path caCert,
|
|
Path caKey,
|
|
Path csrFile,
|
|
String certPassword
|
|
) throws CommandLineOperationException, InterruptedException {
|
|
Path outFile = csrFile.resolveSibling(csrFile.getFileName().toString().replace(".csr", ".crt"));
|
|
log.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<String> altNames = Optional
|
|
.ofNullable(request.getExtension())
|
|
.map(CertificateRequestExtension::getAlternativeNames)
|
|
.orElse(List.of());
|
|
if (!altNames.isEmpty()) {
|
|
AtomicInteger counter = new AtomicInteger(1);
|
|
String altNamesContent = altNames
|
|
.stream()
|
|
.map(name -> "DNS.%d = %s".formatted(counter.getAndIncrement(), name))
|
|
.collect(Collectors.joining("\n"));
|
|
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?", "");
|
|
}
|
|
|
|
log.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 getCertificateFingerprint(Path certificate)
|
|
throws CommandLineOperationException, InterruptedException {
|
|
try {
|
|
StartedProcess fingerprintProc = new ProcessExecutor()
|
|
.command(resolveOpenSSL(), "x509", "-in", certificate.toString(), "-noout", "-fingerprint")
|
|
.readOutput(true)
|
|
.redirectError(Slf4jStream.ofCaller().asError())
|
|
.start();
|
|
var fingerprintResult = fingerprintProc.getFuture().get();
|
|
String output = fingerprintResult.getOutput().getUTF8();
|
|
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 e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
private String resolveOpenSSL() throws CommandLineOperationException {
|
|
try {
|
|
return executableResolver.getOpenSSLPath();
|
|
} catch (UnresolvableCLIDependency e) {
|
|
throw new CommandLineOperationException(e);
|
|
}
|
|
}
|
|
}
|