From d725d7d24943adfb64c0950994f0ec0bdbd3016b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Le=C3=9Fmann=20=28=40MarkL4YG=29?= Date: Sat, 28 Dec 2024 09:45:19 +0100 Subject: [PATCH] feat: Read notBefore/After and serial from x509 command - Added notBefore/After to the DB entity - API changed to a command output oriented record instead of the certificate request object to separate concerns --- .../certassist/models/Certificate.java | 5 +- .../certassist/openssl/OpenSSLService.java | 56 +++++++-- .../openssl/X509CertificateInfo.java | 37 ++++++ .../service/CertificateCreationService.java | 38 +++++- .../certassist/TestOpenSSLService.java | 119 +++++++++--------- .../CertificateRepositoryTest.java | 5 +- 6 files changed, 184 insertions(+), 76 deletions(-) create mode 100644 src/main/java/de/mlessmann/certassist/openssl/X509CertificateInfo.java diff --git a/src/main/java/de/mlessmann/certassist/models/Certificate.java b/src/main/java/de/mlessmann/certassist/models/Certificate.java index 8cc4917..6150139 100644 --- a/src/main/java/de/mlessmann/certassist/models/Certificate.java +++ b/src/main/java/de/mlessmann/certassist/models/Certificate.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotNull; import lombok.*; import org.hibernate.proxy.HibernateProxy; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -32,8 +33,8 @@ public class Certificate { @Min(1) private int requestedKeyLength; - @Min(1) - private int requestedValidityDays; + private OffsetDateTime notBefore; + private OffsetDateTime notAfter; @NotNull private String subjectCommonName; diff --git a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java index 294a623..9355796 100644 --- a/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java +++ b/src/main/java/de/mlessmann/certassist/openssl/OpenSSLService.java @@ -22,6 +22,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.cert.X509Certificate; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.time.temporal.IsoFields; import java.util.*; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -65,6 +71,21 @@ public class OpenSSLService { private static final Pattern FINGERPRINT_EXTRACTOR = Pattern.compile( "^(?[0-9a-zA-Z]+) (?i)Fingerprint(?-i)=(?[a-z:A-Z0-9]+)" ); + private final DateTimeFormatter OSSL_DATE_TIME = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendValue(ChronoField.YEAR, 4) + .appendLiteral('-') + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendLiteral('-') + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendLiteral(' ') + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendOffset("+HH:MM:ss","Z") + .toFormatter(); private static final String OSSL_ENV_KEY_PW = "KEY_PASS"; private static final String OSSL_ARG_KEY_PW = "env:" + OSSL_ENV_KEY_PW; private final AtomicBoolean versionLogged = new AtomicBoolean(false); @@ -560,7 +581,7 @@ public class OpenSSLService { @NonNull @SneakyThrows - public CertificateInfo getCertificateInfo(String pemContent) { + public X509CertificateInfo getCertificateInfo(String pemContent) { StartedProcess infoProc = null; try (var input = new ByteArrayInputStream(pemContent.getBytes())) { infoProc = Commands.infoCommand(resolveOpenSSL()).redirectInput(input).start(); @@ -581,7 +602,7 @@ public class OpenSSLService { } @NonNull - public CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { + public X509CertificateInfo getCertificateInfo(Path path) throws CommandLineOperationException { requireNonNull(path, "Certificate file must be provided to read the info."); try { return getCertificateInfo(Files.readString(path)); @@ -620,9 +641,12 @@ public class OpenSSLService { } } - private CertificateInfo getCertificateInfo(String[] lines) { - var builder = CertificateInfo.builder(); - boolean hasIssuer = false; + /** + * Reads the OpenSSL x509 command output to provide the requested certificate information + */ + private X509CertificateInfo getCertificateInfo(String[] lines) { + var builder = X509CertificateInfo.builder(); + List extensions = new ArrayList<>(); for (int i = 0; i < lines.length; i++) { String line = lines[i]; @@ -634,9 +658,8 @@ public class OpenSSLService { subjectBuilder = readSubjectInfo(line, subjectBuilder); line = lines[++i]; } - builder = builder.subject(subjectBuilder); + builder = builder.subject(subjectBuilder.build()); } else if (line.startsWith("issuer=")) { - hasIssuer = true; var issuerBuilder = CertificateInfoSubject.builder(); line = lines[++i]; @@ -645,14 +668,24 @@ public class OpenSSLService { line = lines[++i]; } - builder = builder.issuer(issuerBuilder); + builder = builder.issuer(issuerBuilder.build()); } else if (line.startsWith("X509v3 Subject Alternative Name")) { String[] altNames = lines[++i].split(","); - builder = builder.extension(CertificateInfoExtension.builder().alternativeDnsNames(altNames)); + extensions.add(CertificateInfoExtension.builder().alternativeDnsNames(altNames).build()); + } else if (line.startsWith("serial=")) { + builder = builder.serial(line.substring("serial=".length())); + } else if (line.startsWith("notBefore=")) { + String notBeforeStr = line.substring("notBefore=".length()); + var notBefore = OffsetDateTime.parse(notBeforeStr, OSSL_DATE_TIME); + builder = builder.notBefore(notBefore); + } else if (line.startsWith("notAfter=")) { + String notAfterStr = line.substring("notAfter=".length()); + var notAfter = OffsetDateTime.parse(notAfterStr, OSSL_DATE_TIME); + builder = builder.notAfter(notAfter); } } - builder = builder.type(hasIssuer ? RequestType.NORMAL_CERTIFICATE : RequestType.STANDALONE_CERTIFICATE); + builder = builder.extensions(extensions); return builder.build(); } @@ -730,7 +763,8 @@ public class OpenSSLService { "-nameopt", "sep_multiline", "-nameopt", - "lname" + "lname", + "-modulus" ) ); command.addAll(Arrays.asList(additArgs)); diff --git a/src/main/java/de/mlessmann/certassist/openssl/X509CertificateInfo.java b/src/main/java/de/mlessmann/certassist/openssl/X509CertificateInfo.java new file mode 100644 index 0000000..3f81078 --- /dev/null +++ b/src/main/java/de/mlessmann/certassist/openssl/X509CertificateInfo.java @@ -0,0 +1,37 @@ +package de.mlessmann.certassist.openssl; + +import de.mlessmann.certassist.models.CertificateInfoExtension; +import de.mlessmann.certassist.models.CertificateInfoSubject; +import lombok.Builder; +import org.springframework.lang.Nullable; + +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +@Builder +public record X509CertificateInfo( + CertificateInfoSubject subject, + @Nullable CertificateInfoSubject issuer, + String serial, + OffsetDateTime notBefore, + OffsetDateTime notAfter, + List extensions +) { + public X509CertificateInfo { + Objects.requireNonNull(subject); + Objects.requireNonNull(serial); + Objects.requireNonNull(notBefore); + Objects.requireNonNull(notAfter); + } + + @Override + public List extensions() { + if (extensions == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(extensions); + } +} diff --git a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java index ea0ada0..1383116 100644 --- a/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java +++ b/src/main/java/de/mlessmann/certassist/service/CertificateCreationService.java @@ -7,6 +7,7 @@ import de.mlessmann.certassist.repositories.CertificateRepository; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.OffsetDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,7 +56,9 @@ public class CertificateCreationService { certificate.setSubjectCommonName(certificateInfo.getSubject().getCommonName()); certificate.setTrustingAuthority(certificateInfo.getTrustingAuthority()); certificate.setRequestedKeyLength(certificateInfo.getRequestedKeyLength()); - certificate.setRequestedValidityDays(certificateInfo.getRequestedValidityDays()); + certificate.setNotBefore(OffsetDateTime.now()); + certificate.setNotAfter(OffsetDateTime.now().plusDays(certificateInfo.getRequestedValidityDays())); + final CertificateInfoSubject subjectInfo = certificateInfo.getSubject(); certificate.setSubjectEmailAddress(subjectInfo.getEmailAddress()); certificate.setSubjectOrganization(subjectInfo.getOrganization()); @@ -74,6 +77,33 @@ public class CertificateCreationService { return certificate; } + private Certificate createEntityFromInfo(X509CertificateInfo info) { + final Certificate certificate = new Certificate(); + certificate.setType(mapCertificateRequestType(info.issuer() != null ? CertificateInfo.RequestType.NORMAL_CERTIFICATE : CertificateInfo.RequestType.STANDALONE_CERTIFICATE)); + certificate.setSubjectCommonName(info.subject().getCommonName()); + certificate.setTrustingAuthority(info.issuer().getCommonName()); + certificate.setRequestedKeyLength(-1); + certificate.setNotBefore(info.notBefore()); + certificate.setNotAfter(info.notAfter()); + + final CertificateInfoSubject subjectInfo = info.subject(); + certificate.setSubjectEmailAddress(subjectInfo.getEmailAddress()); + certificate.setSubjectOrganization(subjectInfo.getOrganization()); + certificate.setSubjectOrganizationalUnit(subjectInfo.getOrganizationalUnit()); + certificate.setSubjectCountry(subjectInfo.getCountry()); + certificate.setSubjectState(subjectInfo.getState()); + certificate.setSubjectLocality(subjectInfo.getLocality()); + + final CertificateInfoExtension extension = info.extensions().getFirst(); + if (extension != null) { + final CertificateExtension certificateExtension = new CertificateExtension(); + certificateExtension.setIdentifier("alternativeNames"); + certificateExtension.setValue(String.join(",", extension.getAlternativeDnsNames())); + certificate.setCertificateExtension(List.of(certificateExtension)); + } + return certificate; + } + @NonNull public Certificate importCertificate( @NonNull Path certificate, @@ -82,8 +112,7 @@ public class CertificateCreationService { ) { try { String fingerprint = openSSLService.getCertificateFingerprint(certificate); - var generatedRequest = openSSLService.getCertificateInfo(certificate); - Certificate entity = createEntityFromRequest(generatedRequest); + Certificate entity = createEntityFromInfo(openSSLService.getCertificateInfo(certificate)); entity.setFingerprint(fingerprint); entity.setCert(Files.readAllBytes(certificate)); if (keyFile != null) { @@ -113,8 +142,7 @@ public class CertificateCreationService { int endIdx = endMatcher.end(); String singleCert = pemContent.substring(startIdx, endIdx); String fingerprint = openSSLService.getCertificateFingerprint(singleCert); - var generatedRequest = openSSLService.getCertificateInfo(singleCert); - Certificate entity = createEntityFromRequest(generatedRequest); + Certificate entity = createEntityFromInfo(openSSLService.getCertificateInfo(singleCert)); entity.setFingerprint(fingerprint); entity.setCert(singleCert.getBytes()); certsInBundle.put(fingerprint, entity); diff --git a/src/test/java/de/mlessmann/certassist/TestOpenSSLService.java b/src/test/java/de/mlessmann/certassist/TestOpenSSLService.java index 2055b4f..c25780f 100644 --- a/src/test/java/de/mlessmann/certassist/TestOpenSSLService.java +++ b/src/test/java/de/mlessmann/certassist/TestOpenSSLService.java @@ -1,28 +1,31 @@ package de.mlessmann.certassist; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - import de.mlessmann.certassist.models.CertificateInfo; import de.mlessmann.certassist.models.CertificateInfo.RequestType; import de.mlessmann.certassist.models.CertificateInfoExtension; import de.mlessmann.certassist.models.CertificateInfoSubject; -import de.mlessmann.certassist.openssl.*; +import de.mlessmann.certassist.openssl.CertificatePasswordProvider; +import de.mlessmann.certassist.openssl.CertificateProvider; +import de.mlessmann.certassist.openssl.OpenSSLService; import de.mlessmann.certassist.service.ExecutableResolver; -import java.nio.file.Path; -import java.util.Objects; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import java.nio.file.Path; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + @SpringBootTest class TestOpenSSLService { public static final String TEST_CERT_PASSPHRASE = "ABC-123"; public static final Path TEST_CERT_PATH = Path.of("src/test/resources/openssl"); public static final String TEST_CERT_FINGERPRINT = - "SHA1;4E:D6:0A:47:F0:63:AD:96:26:83:16:28:32:F5:E8:36:5A:62:91:95"; + "SHA1;4E:D6:0A:47:F0:63:AD:96:26:83:16:28:32:F5:E8:36:5A:62:91:95"; private static final String ERR_NOT_ENCRYPTED = "Private key not encrypted"; private static final String ERR_VERIFY_FAILED = "Certificate verification failed"; @@ -42,43 +45,43 @@ class TestOpenSSLService { var certificateCreator = new OpenSSLService(executableResolver, passwordProvider, certificateProvider); CertificateInfo certRequest = CertificateInfo - .builder() - .type(RequestType.STANDALONE_CERTIFICATE) - .subject( - CertificateInfoSubject - .builder() - .commonName("test.home") - .country("DE") - .state("SH") - .locality("HH") - .organization("Crazy-Cats") - ) - .extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.home", "test3.home")) - .build(); + .builder() + .type(RequestType.STANDALONE_CERTIFICATE) + .subject( + CertificateInfoSubject + .builder() + .commonName("test.home") + .country("DE") + .state("SH") + .locality("HH") + .organization("Crazy-Cats") + ) + .extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.home", "test3.home")) + .build(); try (var cert = certificateCreator.createCertificate(certRequest)) { assertThat(certificateCreator.verifyCertificate(cert.certificatePath(), cert.certificatePath())) - .withFailMessage(ERR_VERIFY_FAILED) - .isTrue(); - assertThat(certificateCreator.isKeyEncrypted(cert.certificateKeyPath())) - .withFailMessage(ERR_NOT_ENCRYPTED) - .isTrue(); + .withFailMessage(ERR_VERIFY_FAILED) + .isTrue(); + assertThat(certificateCreator.isKeyEncrypted(requireNonNull(cert.certificateKeyPath()))) + .withFailMessage(ERR_NOT_ENCRYPTED) + .isTrue(); CertificateInfo childRequest = CertificateInfo - .builder() - .type(RequestType.NORMAL_CERTIFICATE) - .trustingAuthority(cert.fingerprint()) - .subject( - CertificateInfoSubject - .builder() - .commonName("test.local") - .country("DE") - .state("SH") - .locality("HH") - .organization("Crazy-Cats") - ) - .extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.local", "test3.local")) - .build(); + .builder() + .type(RequestType.NORMAL_CERTIFICATE) + .trustingAuthority(cert.fingerprint()) + .subject( + CertificateInfoSubject + .builder() + .commonName("test.local") + .country("DE") + .state("SH") + .locality("HH") + .organization("Crazy-Cats") + ) + .extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.local", "test3.local")) + .build(); var spiedCert = spy(cert); doNothing().when(spiedCert).close(); @@ -86,13 +89,13 @@ class TestOpenSSLService { try (var childCert = certificateCreator.createCertificate(childRequest)) { Path fullchain = childCert.fullchainPath(); assertThat( - certificateCreator.verifyCertificate(Objects.requireNonNull(fullchain), cert.certificatePath()) + certificateCreator.verifyCertificate(requireNonNull(fullchain), cert.certificatePath()) ) - .withFailMessage(ERR_VERIFY_FAILED) - .isTrue(); - assertThat(certificateCreator.isKeyEncrypted(childCert.certificateKeyPath())) - .withFailMessage(ERR_NOT_ENCRYPTED) - .isTrue(); + .withFailMessage(ERR_VERIFY_FAILED) + .isTrue(); + assertThat(certificateCreator.isKeyEncrypted(requireNonNull(childCert.certificateKeyPath()))) + .withFailMessage(ERR_NOT_ENCRYPTED) + .isTrue(); } } } @@ -105,20 +108,22 @@ class TestOpenSSLService { var request = certificateCreator.getCertificateInfo(TEST_CERT_PATH.resolve("x509forImportCA.pem")); assertThat(request).isNotNull(); - assertThat(request.getSubject().getCommonName()).isEqualTo("test.home"); - assertThat(request.getSubject().getCountry()).isEqualTo("DE"); - assertThat(request.getSubject().getState()).isEqualTo("SH"); - assertThat(request.getSubject().getLocality()).isEqualTo("HH"); - assertThat(request.getSubject().getOrganization()).isEqualTo("Crazy-Cats"); - assertThat(request.getExtension()).isNull(); + assertThat(request.subject().getCommonName()).isEqualTo("test.home"); + assertThat(request.subject().getCountry()).isEqualTo("DE"); + assertThat(request.subject().getState()).isEqualTo("SH"); + assertThat(request.subject().getLocality()).isEqualTo("HH"); + assertThat(request.subject().getOrganization()).isEqualTo("Crazy-Cats"); + assertThat(request.notBefore()).isEqualTo("2024-11-22T18:57:40Z"); + assertThat(request.notAfter()).isEqualTo("2025-11-22T18:57:40Z"); + assertThat(request.extensions()).isEmpty(); request = certificateCreator.getCertificateInfo(TEST_CERT_PATH.resolve("x509forImport.pem")); assertThat(request).isNotNull(); - assertThat(request.getSubject().getCommonName()).isEqualTo("test.local"); - assertThat(request.getSubject().getCountry()).isEqualTo("DE"); - assertThat(request.getSubject().getState()).isEqualTo("SH"); - assertThat(request.getSubject().getLocality()).isEqualTo("HH"); - assertThat(request.getSubject().getOrganization()).isEqualTo("Crazy-Cats"); - assertThat(request.getExtension().getAlternativeDnsNames()).containsExactly("test2.local", "test3.local"); + assertThat(request.subject().getCommonName()).isEqualTo("test.local"); + assertThat(request.subject().getCountry()).isEqualTo("DE"); + assertThat(request.subject().getState()).isEqualTo("SH"); + assertThat(request.subject().getLocality()).isEqualTo("HH"); + assertThat(request.subject().getOrganization()).isEqualTo("Crazy-Cats"); + assertThat(request.extensions().getFirst().getAlternativeDnsNames()).containsExactly("test2.local", "test3.local"); } } diff --git a/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java b/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java index 4d95f48..e142fa4 100644 --- a/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java +++ b/src/test/java/de/mlessmann/certassist/repositories/CertificateRepositoryTest.java @@ -6,6 +6,8 @@ import de.mlessmann.certassist.models.Certificate; import de.mlessmann.certassist.models.CertificateExtension; import de.mlessmann.certassist.models.CertificateType; import jakarta.transaction.Transactional; + +import java.time.OffsetDateTime; import java.util.List; import java.util.stream.StreamSupport; import org.junit.jupiter.api.BeforeEach; @@ -33,7 +35,8 @@ class CertificateRepositoryTest { certificate.setSubjectCommonName("test-cn"); certificate.setType(CertificateType.SIGNED_CERT); certificate.setRequestedKeyLength(1); - certificate.setRequestedValidityDays(1); + certificate.setNotBefore(OffsetDateTime.now()); + certificate.setNotAfter(OffsetDateTime.now().plusDays(1)); return certificate; }