feat: Implement import of CA bundles to keystores

This commit is contained in:
Magnus Leßmann (@MarkL4YG) 2024-11-24 12:44:06 +01:00
parent a4f495ab91
commit 6b1c969ce6
8 changed files with 207 additions and 7 deletions

View file

@ -15,7 +15,9 @@ import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import lombok.RequiredArgsConstructor;
@ -30,7 +32,7 @@ public class KeyStoreManager {
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
};
private final OpenSSLService certificateCreator;
private final OpenSSLService openSSLService;
private final CertificatePasswordProvider passwordProvider;
private final AtomicReference<CertificateFactory> certFactory = new AtomicReference<>();
@ -57,7 +59,15 @@ public class KeyStoreManager {
}
}
@SneakyThrows
public KeystoreUsage createTruststore(String truststorePassphrase, CertificateUsage... trustedCertificates) {
return createTruststore(truststorePassphrase, Arrays.asList(trustedCertificates));
}
public KeystoreUsage createTruststore(
String truststorePassphrase,
Collection<CertificateUsage> trustedCertificates
) {
try {
Path truststorePath = Files.createTempFile("truststore", ".jks");
truststorePath.toFile().deleteOnExit();
@ -90,9 +100,15 @@ public class KeyStoreManager {
public void loadPublicKeyIntoTrust(KeyStore keyStore, CertificateUsage cert) throws KeyStoreException {
try (var inputStream = Files.newInputStream(cert.certificatePath())) {
var parsedCert = getX509Factory().generateCertificate(inputStream);
Collection<? extends Certificate> parsedCerts = getX509Factory().generateCertificates(inputStream);
if (parsedCerts.size() != 1) {
throw new IllegalStateException(
"CertificateUsage parsed to not exactly one result! " + parsedCerts.size()
);
}
Certificate parsedCert = parsedCerts.stream().toList().getFirst();
if (!(parsedCert instanceof X509Certificate x509cert)) {
throw new IllegalStateException("CertificateUsage did not parse to X509 certificate??");
throw new IllegalStateException("CertificateUsage did not parse to X509 format??");
}
keyStore.setCertificateEntry("TRUST;%s".formatted(cert.fingerprint()), x509cert);
} catch (IOException e) {
@ -105,8 +121,8 @@ public class KeyStoreManager {
@SneakyThrows
private PrivateKey loadPrivateKey(Path privateKey, String passphrase) {
String pemContent;
if (certificateCreator.isKeyEncrypted(privateKey)) {
pemContent = certificateCreator.readDecryptedKey(privateKey, passphrase);
if (openSSLService.isKeyEncrypted(privateKey)) {
pemContent = openSSLService.readDecryptedKey(privateKey, passphrase);
} else {
pemContent = Files.readString(privateKey);
}

View file

@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Entity
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "fingerprint" }) })
@Data
@RequiredArgsConstructor
public class Certificate {
@ -57,5 +58,6 @@ public class Certificate {
@Column
private byte[] fullchain;
@Column(nullable = false)
private String fingerprint;
}

View file

@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ -30,6 +31,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
@ -535,6 +537,51 @@ public class OpenSSLService {
}
}
@NonNull
@SneakyThrows
public String getCertificateFingerPrint(X509Certificate jdkCert) {
String pemContent =
"-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----".formatted(
new String(jdkCert.getEncoded(), StandardCharsets.UTF_8)
);
return getCertificateFingerprint(pemContent);
}
@NonNull
@SneakyThrows
public String getCertificateFingerprint(@NonNull String pemContent) throws CommandLineOperationException {
requireNonNull(pemContent, "Certificate PEM content must be provided to generate fingerprint from string.");
Path tmpFile = Files.createTempFile(CERTASSIST_TMP_PREFIX, ".pem");
try {
Files.writeString(tmpFile, pemContent);
return getCertificateFingerprint(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
@SneakyThrows
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.");

View file

@ -7,13 +7,20 @@ import de.mlessmann.certassist.repositories.CertificateRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class CertificateCreationService {
@ -21,12 +28,15 @@ public class CertificateCreationService {
private final CertificateRepository certificateRepository;
private final OpenSSLService openSSLService;
private final PassphraseService passphraseService;
private final Pattern CERT_START_PATTERN = Pattern.compile("-+BEGIN CERTIFICATE-+");
private final Pattern CERT_END_PATTERN = Pattern.compile("-+END CERTIFICATE-+");
public Certificate createCertificate(final CertificateInfo certificateInfo) {
final Certificate certificate = createEntityFromRequest(certificateInfo);
try (OpenSSLCertificateResult certificateCreatorResult = openSSLService.createCertificate(certificateInfo);) {
Path keyPath = certificateCreatorResult.certificateKeyPath();
certificate.setFingerprint(certificateCreatorResult.fingerprint());
if (keyPath != null) {
certificate.setPrivateKey(Files.readAllBytes(keyPath));
}
@ -74,6 +84,7 @@ public class CertificateCreationService {
String fingerprint = openSSLService.getCertificateFingerprint(certificate);
var generatedRequest = openSSLService.getCertificateInfo(certificate);
Certificate entity = createEntityFromRequest(generatedRequest);
entity.setFingerprint(fingerprint);
entity.setCert(Files.readAllBytes(certificate));
if (keyFile != null) {
entity.setPrivateKey(Files.readAllBytes(keyFile));
@ -87,6 +98,36 @@ public class CertificateCreationService {
}
}
public List<Certificate> importCertificateTrustBundle(@NonNull Path bundleFile) {
try {
Map<String, Certificate> certsInBundle = new HashMap<>();
String pemContent = Files.readString(bundleFile);
Matcher beginMatcher = CERT_START_PATTERN.matcher(pemContent);
while (beginMatcher.find()) {
int startIdx = beginMatcher.start();
Matcher endMatcher = CERT_END_PATTERN.matcher(pemContent);
if (!endMatcher.find(startIdx)) {
throw new IllegalStateException("Certificate has a startIdx but not an end??");
}
int endIdx = endMatcher.end();
String singleCert = pemContent.substring(startIdx, endIdx);
String fingerprint = openSSLService.getCertificateFingerprint(singleCert);
var generatedRequest = openSSLService.getCertificateInfo(singleCert);
Certificate entity = createEntityFromRequest(generatedRequest);
entity.setFingerprint(fingerprint);
entity.setCert(singleCert.getBytes());
certsInBundle.put(fingerprint, entity);
log.debug("Found certificate in bundle at {} to {}: {}", startIdx, endIdx, fingerprint);
}
var saveResult = certificateRepository.saveAll(certsInBundle.values());
return StreamSupport.stream(saveResult.spliterator(), false).toList();
} catch (CommandLineOperationException | IOException e) {
throw new RuntimeException("Unable to import certificate", e);
}
}
private CertificateType mapCertificateRequestType(CertificateInfo.RequestType requestType) {
return switch (requestType) {
case ROOT_AUTHORITY -> CertificateType.ROOT_CA;

View file

@ -1,5 +1,7 @@
package de.mlessmann.certassist.service;
import static java.util.Objects.requireNonNull;
import de.mlessmann.certassist.Constants;
import de.mlessmann.certassist.DeleteRecursiveFileVisitor;
import de.mlessmann.certassist.models.Certificate;
@ -29,7 +31,14 @@ public class CertificateProviderImpl implements CertificateProvider {
@Override
public CertificateUsage requestCertificateUsage(String fingerprint) {
Certificate certFromDB = certificateRepository.findByFingerprintIs(fingerprint);
requireNonNull(fingerprint, "Fingerprint must be provided.");
Certificate certFromDB;
try {
certFromDB = certificateRepository.findByFingerprintIs(fingerprint);
} catch (RuntimeException e) {
log.error("Failed to retrieve certificate from database by fingerprint: {}", fingerprint);
throw e;
}
if (certFromDB == null) {
throw new IllegalArgumentException("Unknown fingerprint");
}