feat: Verify chain of trust on generated certificates #14
7 changed files with 131 additions and 8 deletions
|
@ -7,6 +7,7 @@ import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.SystemUtils;
|
import org.apache.commons.lang3.SystemUtils;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
@ -16,6 +17,8 @@ import org.springframework.stereotype.Service;
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ExecutableResolver {
|
public class ExecutableResolver {
|
||||||
|
|
||||||
|
private static final AtomicBoolean loggedPath = new AtomicBoolean(false);
|
||||||
|
|
||||||
@Value("${openssl.path:#{null}}")
|
@Value("${openssl.path:#{null}}")
|
||||||
private String opensslPath;
|
private String opensslPath;
|
||||||
|
|
||||||
|
@ -39,6 +42,13 @@ public class ExecutableResolver {
|
||||||
Objects.requireNonNull(envPath, "Environment variable 'PATH' is not set?!");
|
Objects.requireNonNull(envPath, "Environment variable 'PATH' is not set?!");
|
||||||
String[] pathEntries = envPath.split(File.pathSeparator);
|
String[] pathEntries = envPath.split(File.pathSeparator);
|
||||||
|
|
||||||
|
if (!loggedPath.get()) {
|
||||||
|
loggedPath.set(true);
|
||||||
|
for (String pathEntry : pathEntries) {
|
||||||
|
log.atError().log("Path entry: {}", pathEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (String pathEntry : pathEntries) {
|
for (String pathEntry : pathEntries) {
|
||||||
for (String fileExtension : getAllowedExtensions()) {
|
for (String fileExtension : getAllowedExtensions()) {
|
||||||
Path executablePath = Path.of(pathEntry, executableName + fileExtension);
|
Path executablePath = Path.of(pathEntry, executableName + fileExtension);
|
||||||
|
|
|
@ -46,10 +46,16 @@ public class Certificate {
|
||||||
private List<CertificateExtension> certificateExtension = new ArrayList<>();
|
private List<CertificateExtension> certificateExtension = new ArrayList<>();
|
||||||
|
|
||||||
@Lob
|
@Lob
|
||||||
|
@Column(nullable = false)
|
||||||
private byte[] cert = new byte[0];
|
private byte[] cert = new byte[0];
|
||||||
|
|
||||||
@Lob
|
@Lob
|
||||||
|
@Column(nullable = false)
|
||||||
private byte[] privateKey = new byte[0];
|
private byte[] privateKey = new byte[0];
|
||||||
|
|
||||||
|
@Lob
|
||||||
|
@Column
|
||||||
|
private byte[] fullchain;
|
||||||
|
|
||||||
private String fingerprint;
|
private String fingerprint;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package de.mlessmann.certassist.openssl;
|
package de.mlessmann.certassist.openssl;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance of a certificate that is temporarily stored on disk to be available for use in command line calls.
|
* Instance of a certificate that is temporarily stored on disk to be available for use in command line calls.
|
||||||
|
@ -8,8 +9,30 @@ import java.nio.file.Path;
|
||||||
* @implSpec The files should be removed from disk when the instance is closed, UNLESS the provided paths are the permanent storage location for the certificate files.
|
* @implSpec The files should be removed from disk when the instance is closed, UNLESS the provided paths are the permanent storage location for the certificate files.
|
||||||
*/
|
*/
|
||||||
public interface CertificateUsage extends AutoCloseable {
|
public interface CertificateUsage extends AutoCloseable {
|
||||||
|
/**
|
||||||
|
* Returns the path to the certificate file (on disk, potentially temporary depending on the storage implementation).
|
||||||
|
*/
|
||||||
Path certificatePath();
|
Path certificatePath();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the private key file (on disk, potentially temporary depending on the storage implementation).
|
||||||
|
* This file should also be encrypted.
|
||||||
|
* @see CertificatePasswordProvider
|
||||||
|
*/
|
||||||
Path certificateKeyPath();
|
Path certificateKeyPath();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the fullchain file (on disk, potentially temporary depending on the storage implementation).
|
||||||
|
* This should contain the entire certification chain from (inclusive) the certificate to the root authority (inclusive).
|
||||||
|
* @implSpec This method may return {@code null} if the certificate is self-signed.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
Path fullchainPath();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String representation of the certificate's fingerprint.
|
||||||
|
* In case of OpenSSL, this should be in the form of: {@code SHA1;<HEX>:<HEX>:...}
|
||||||
|
*/
|
||||||
String fingerprint();
|
String fingerprint();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -2,6 +2,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 de.mlessmann.certassist.DeleteRecursiveFileVisitor;
|
||||||
import de.mlessmann.certassist.ExecutableResolver;
|
import de.mlessmann.certassist.ExecutableResolver;
|
||||||
import de.mlessmann.certassist.except.CommandLineOperationException;
|
import de.mlessmann.certassist.except.CommandLineOperationException;
|
||||||
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
|
import de.mlessmann.certassist.except.UnresolvableCLIDependency;
|
||||||
|
@ -23,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.zeroturnaround.exec.ProcessExecutor;
|
import org.zeroturnaround.exec.ProcessExecutor;
|
||||||
import org.zeroturnaround.exec.StartedProcess;
|
import org.zeroturnaround.exec.StartedProcess;
|
||||||
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
|
import org.zeroturnaround.exec.stream.slf4j.Slf4jStream;
|
||||||
|
@ -87,7 +89,7 @@ public class OpenSSLCertificateCreator {
|
||||||
Path certificate = createCertificate(request, keyFile, tmpDir.resolve("certificate.crt"), certPassword);
|
Path certificate = createCertificate(request, keyFile, tmpDir.resolve("certificate.crt"), certPassword);
|
||||||
String fingerprint = getCertificateFingerprint(certificate);
|
String fingerprint = getCertificateFingerprint(certificate);
|
||||||
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
||||||
return new OpenSSLCertificateResult(tmpDir, certificate, keyFile, fingerprint);
|
return new OpenSSLCertificateResult(tmpDir, certificate, keyFile, certificate, fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) {
|
try (var certAuthority = certificateProvider.requestCertificateUsage(request.getTrustingAuthority())) {
|
||||||
|
@ -101,7 +103,19 @@ public class OpenSSLCertificateCreator {
|
||||||
);
|
);
|
||||||
String fingerprint = getCertificateFingerprint(signedCert);
|
String fingerprint = getCertificateFingerprint(signedCert);
|
||||||
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
passwordProvider.setPasswordFor(fingerprint, certPassword);
|
||||||
return new OpenSSLCertificateResult(tmpDir, signedCert, keyFile, fingerprint);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,17 +225,56 @@ public class OpenSSLCertificateCreator {
|
||||||
return outFile;
|
return outFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean verifyCertificate(Path certFile) throws CommandLineOperationException {
|
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 {
|
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()
|
StartedProcess verifyCommand = new ProcessExecutor()
|
||||||
.command(resolveOpenSSL(), "x509", "-in", certFile.toString(), "-text", "-noout")
|
.command(resolveOpenSSL(), "verify", "-CAfile", tempTrustedBundle.toString(), fullChainFile.toString())
|
||||||
.redirectOutput(Slf4jStream.ofCaller().asDebug())
|
.redirectOutput(Slf4jStream.ofCaller().asError())
|
||||||
.redirectError(Slf4jStream.ofCaller().asError())
|
.redirectError(Slf4jStream.ofCaller().asError())
|
||||||
.start();
|
.start();
|
||||||
var verifyResult = verifyCommand.getFuture().get();
|
var verifyResult = verifyCommand.getFuture().get();
|
||||||
return verifyResult.getExitValue() == 0;
|
return verifyResult.getExitValue() == 0;
|
||||||
} catch (IOException | InterruptedException | ExecutionException e) {
|
} catch (IOException | InterruptedException | ExecutionException e) {
|
||||||
throw new RuntimeException(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ public class OpenSSLCertificateResult implements CertificateUsage {
|
||||||
private final Path tmpDir;
|
private final Path tmpDir;
|
||||||
private final Path certificatePath;
|
private final Path certificatePath;
|
||||||
private final Path privateKeyPath;
|
private final Path privateKeyPath;
|
||||||
|
private final Path fullchainPath;
|
||||||
private final String certificateFingerPrint;
|
private final String certificateFingerPrint;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -28,6 +29,11 @@ public class OpenSSLCertificateResult implements CertificateUsage {
|
||||||
return privateKeyPath;
|
return privateKeyPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path fullchainPath() {
|
||||||
|
return fullchainPath;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String fingerprint() {
|
public String fingerprint() {
|
||||||
return certificateFingerPrint;
|
return certificateFingerPrint;
|
||||||
|
|
|
@ -2,6 +2,8 @@ package de.mlessmann.certassist.service;
|
||||||
|
|
||||||
import de.mlessmann.certassist.Constants;
|
import de.mlessmann.certassist.Constants;
|
||||||
import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
|
import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
|
||||||
|
import de.mlessmann.certassist.models.Certificate;
|
||||||
|
import de.mlessmann.certassist.models.CertificateType;
|
||||||
import de.mlessmann.certassist.openssl.CertificateProvider;
|
import de.mlessmann.certassist.openssl.CertificateProvider;
|
||||||
import de.mlessmann.certassist.openssl.CertificateUsage;
|
import de.mlessmann.certassist.openssl.CertificateUsage;
|
||||||
import de.mlessmann.certassist.repositories.CertificateRepository;
|
import de.mlessmann.certassist.repositories.CertificateRepository;
|
||||||
|
@ -27,15 +29,22 @@ public class CertificateProviderImpl implements CertificateProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CertificateUsage requestCertificateUsage(String fingerprint) {
|
public CertificateUsage requestCertificateUsage(String fingerprint) {
|
||||||
var certFromDB = certificateRepository.findByFingerprintIs(fingerprint);
|
Certificate certFromDB = certificateRepository.findByFingerprintIs(fingerprint);
|
||||||
if (certFromDB == null) {
|
if (certFromDB == null) {
|
||||||
throw new IllegalArgumentException("Unknown fingerprint");
|
throw new IllegalArgumentException("Unknown fingerprint");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean selfSigned =
|
||||||
|
certFromDB.getType() == CertificateType.ROOT_CA || certFromDB.getType() == CertificateType.STANDALONE_CERT;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Path tempDirectory = Files.createTempDirectory(Constants.CERTASSIST_TMP_PREFIX);
|
Path tempDirectory = Files.createTempDirectory(Constants.CERTASSIST_TMP_PREFIX);
|
||||||
Files.write(tempDirectory.resolve("key.pem"), certFromDB.getPrivateKey(), CREATE_TRUNCATE);
|
Files.write(tempDirectory.resolve("key.pem"), certFromDB.getPrivateKey(), CREATE_TRUNCATE);
|
||||||
Files.write(tempDirectory.resolve("cert.pem"), certFromDB.getCert(), CREATE_TRUNCATE);
|
Files.write(tempDirectory.resolve("cert.pem"), certFromDB.getCert(), CREATE_TRUNCATE);
|
||||||
|
if (!selfSigned) {
|
||||||
|
Files.write(tempDirectory.resolve("fullchain.pem"), certFromDB.getFullchain(), CREATE_TRUNCATE);
|
||||||
|
}
|
||||||
|
|
||||||
return new ExtractedCert(tempDirectory, certFromDB.getFingerprint());
|
return new ExtractedCert(tempDirectory, certFromDB.getFingerprint());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// TODO: Better exception definitions
|
// TODO: Better exception definitions
|
||||||
|
@ -55,6 +64,15 @@ public class CertificateProviderImpl implements CertificateProvider {
|
||||||
return this.tempDir.resolve("cert.pem");
|
return this.tempDir.resolve("cert.pem");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path fullchainPath() {
|
||||||
|
Path fcFile = this.tempDir.resolve("fullchain.pem");
|
||||||
|
if (Files.exists(fcFile)) {
|
||||||
|
return fcFile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -5,6 +5,8 @@ import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
import de.mlessmann.certassist.openssl.*;
|
import de.mlessmann.certassist.openssl.*;
|
||||||
import de.mlessmann.certassist.openssl.CertificateRequest.RequestType;
|
import de.mlessmann.certassist.openssl.CertificateRequest.RequestType;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@ -37,7 +39,8 @@ class TestOpenSSLCertificateCreator {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
try (var cert = certificateCreator.createCertificate(certRequest)) {
|
try (var cert = certificateCreator.createCertificate(certRequest)) {
|
||||||
assertThat(certificateCreator.verifyCertificate(cert.certificatePath())).isEqualTo(true);
|
assertThat(certificateCreator.verifyCertificate(cert.certificatePath(), cert.certificatePath()))
|
||||||
|
.isEqualTo(true);
|
||||||
System.out.println("Certificate created: " + cert);
|
System.out.println("Certificate created: " + cert);
|
||||||
|
|
||||||
CertificateRequest childRequest = CertificateRequest
|
CertificateRequest childRequest = CertificateRequest
|
||||||
|
@ -55,7 +58,11 @@ class TestOpenSSLCertificateCreator {
|
||||||
when(certificateProvider.requestCertificateUsage(cert.fingerprint())).thenReturn(spiedCert);
|
when(certificateProvider.requestCertificateUsage(cert.fingerprint())).thenReturn(spiedCert);
|
||||||
try (var childCert = certificateCreator.createCertificate(childRequest)) {
|
try (var childCert = certificateCreator.createCertificate(childRequest)) {
|
||||||
System.out.println("Child certificate created: " + childCert);
|
System.out.println("Child certificate created: " + childCert);
|
||||||
assertThat(certificateCreator.verifyCertificate(childCert.certificatePath())).isEqualTo(true);
|
Path fullchain = childCert.fullchainPath();
|
||||||
|
assertThat(
|
||||||
|
certificateCreator.verifyCertificate(cert.certificatePath(), Objects.requireNonNull(fullchain))
|
||||||
|
)
|
||||||
|
.isEqualTo(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue