From ed28bccab823eb5afaf08e40b23f58772b8b1105 Mon Sep 17 00:00:00 2001 From: David Kornel Date: Thu, 25 Jul 2024 11:21:30 +0200 Subject: [PATCH 1/3] Add security utils and cert generating functionality Signed-off-by: David Kornel --- pom.xml | 11 + test-frame-common/pom.xml | 8 + .../skodjob/testframe/TestFrameConstants.java | 10 + .../testframe/security/CertAndKey.java | 45 +++ .../testframe/security/CertAndKeyBuilder.java | 328 ++++++++++++++++++ .../testframe/security/CertAndKeyFiles.java | 34 ++ .../skodjob/testframe/security/OpenSsl.java | 235 +++++++++++++ .../testframe/utils/SecurityUtils.java | 160 +++++++++ 8 files changed, 831 insertions(+) create mode 100644 test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKey.java create mode 100644 test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java create mode 100644 test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyFiles.java create mode 100644 test-frame-common/src/main/java/io/skodjob/testframe/security/OpenSsl.java create mode 100644 test-frame-common/src/main/java/io/skodjob/testframe/utils/SecurityUtils.java diff --git a/pom.xml b/pom.xml index 1155bc6..eeaceb0 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ 3.3.1 2.17.2 0.9.0.M3 + 1.78.1 true @@ -221,6 +222,16 @@ org.eclipse.sisu.inject ${org.eclipse.sisu.version} + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + diff --git a/test-frame-common/pom.xml b/test-frame-common/pom.xml index 86b855d..c87fe6f 100644 --- a/test-frame-common/pom.xml +++ b/test-frame-common/pom.xml @@ -130,6 +130,14 @@ log4j-slf4j2-impl test + + org.bouncycastle + bcpkix-jdk18on + + + org.bouncycastle + bcprov-jdk18on + diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/TestFrameConstants.java b/test-frame-common/src/main/java/io/skodjob/testframe/TestFrameConstants.java index 3590f49..f45acf9 100644 --- a/test-frame-common/src/main/java/io/skodjob/testframe/TestFrameConstants.java +++ b/test-frame-common/src/main/java/io/skodjob/testframe/TestFrameConstants.java @@ -47,6 +47,16 @@ public interface TestFrameConstants { */ long GLOBAL_STABILITY_TIME = Duration.ofMinutes(1).toMillis(); + /** + * CA validity delay + */ + long CA_CERT_VALIDITY_DELAY = 10; + + /** + * Poll interval for resource readiness + */ + long POLL_INTERVAL_FOR_RESOURCE_READINESS = Duration.ofSeconds(1).toMillis(); + /** * OpenShift client type. */ diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKey.java b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKey.java new file mode 100644 index 0000000..af4ba5e --- /dev/null +++ b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKey.java @@ -0,0 +1,45 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.X509Certificate; + +/** + * Record for certificate and key in memory + * + * @param certificate certificate + * @param privateKey key + */ +public record CertAndKey(X509Certificate certificate, PrivateKey privateKey) { + + /** + * Returns certificate + * + * @return certificate + */ + public X509Certificate getCertificate() { + return certificate; + } + + /** + * Returns public key + * + * @return public key + */ + public PublicKey getPublicKey() { + return certificate.getPublicKey(); + } + + /** + * Returns private key + * + * @return private key + */ + public PrivateKey getPrivateKey() { + return privateKey; + } +} diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java new file mode 100644 index 0000000..385d63a --- /dev/null +++ b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java @@ -0,0 +1,328 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1Object; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static java.time.temporal.ChronoUnit.DAYS; +import static java.util.Arrays.asList; +import static org.bouncycastle.asn1.x509.Extension.authorityKeyIdentifier; +import static org.bouncycastle.asn1.x509.Extension.basicConstraints; +import static org.bouncycastle.asn1.x509.Extension.extendedKeyUsage; +import static org.bouncycastle.asn1.x509.Extension.keyUsage; +import static org.bouncycastle.asn1.x509.Extension.subjectAlternativeName; +import static org.bouncycastle.asn1.x509.Extension.subjectKeyIdentifier; +import static org.bouncycastle.asn1.x509.GeneralName.dNSName; +import static org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_clientAuth; +import static org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_serverAuth; +import static org.bouncycastle.asn1.x509.KeyUsage.cRLSign; +import static org.bouncycastle.asn1.x509.KeyUsage.digitalSignature; +import static org.bouncycastle.asn1.x509.KeyUsage.keyCertSign; +import static org.bouncycastle.asn1.x509.KeyUsage.keyEncipherment; +import static org.bouncycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME; + +/** + * Builder of certificates using java + */ +public class CertAndKeyBuilder { + + /** + * Key size + */ + public static final int KEY_SIZE = 2048; + + /** + * Key pair algorithm + */ + public static final String KEY_PAIR_ALGORITHM = "RSA"; + + /** + * Sign algorithm + */ + public static final String SIGNATURE_ALGORITHM = "SHA256WithRSA"; + + /** + * Default cert validity period + */ + public static final Duration CERTIFICATE_VALIDITY_PERIOD = Duration.ofDays(30); + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private final KeyPair keyPair; + private final CertAndKey caCert; + private final List extensions; + private X500Name issuer; + private X500Name subject; + + private CertAndKeyBuilder(KeyPair keyPair, CertAndKey caCert, List extensions) { + this.keyPair = keyPair; + this.caCert = caCert; + if (caCert != null) { + try { + this.issuer = new JcaX509CertificateHolder(caCert.getCertificate()).getSubject(); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + } + this.extensions = new ArrayList<>(extensions); + } + + /** + * Returns builder for root CA + * + * @return builder for root CA + */ + public static CertAndKeyBuilder rootCaCertBuilder() { + KeyPair keyPair = generateKeyPair(); + return new CertAndKeyBuilder( + keyPair, + null, + asList( + new Extension(keyUsage, true, keyUsage(keyCertSign | cRLSign)), + new Extension(basicConstraints, true, ca()), + new Extension(subjectKeyIdentifier, false, createSubjectKeyIdentifier(keyPair.getPublic())) + ) + ); + } + + /** + * Returns builder for intermediate CA + * + * @param caCert ca certificate + * @return builder for intermediate CA + */ + public static CertAndKeyBuilder intermediateCaCertBuilder(CertAndKey caCert) { + KeyPair keyPair = generateKeyPair(); + return new CertAndKeyBuilder( + keyPair, + caCert, + asList( + new Extension(keyUsage, true, keyUsage(keyCertSign)), + new Extension(basicConstraints, true, ca()), + new Extension(subjectKeyIdentifier, false, createSubjectKeyIdentifier(keyPair.getPublic())), + new Extension(authorityKeyIdentifier, false, createAuthorityKeyIdentifier(caCert.getPublicKey())) + ) + ); + } + + /** + * Returns builder for application cert + * + * @param caCert ca certificate + * @return builder for application cert + */ + public static CertAndKeyBuilder appCaCertBuilder(CertAndKey caCert) { + KeyPair keyPair = generateKeyPair(); + return new CertAndKeyBuilder( + keyPair, + caCert, + asList( + new Extension(basicConstraints, true, ca()), + new Extension(subjectKeyIdentifier, false, createSubjectKeyIdentifier(keyPair.getPublic())), + new Extension(authorityKeyIdentifier, false, createAuthorityKeyIdentifier(caCert.getPublicKey())) + ) + ); + } + + /** + * Returns builder for end end entity cert + * + * @param caCert ca certificate + * @return Returns builder for end end entity cert + */ + public static CertAndKeyBuilder endEntityCertBuilder(CertAndKey caCert) { + KeyPair keyPair = generateKeyPair(); + return new CertAndKeyBuilder( + keyPair, + caCert, + asList( + new Extension(keyUsage, true, keyUsage(digitalSignature | keyEncipherment)), + new Extension(extendedKeyUsage, false, extendedKeyUsage(id_kp_serverAuth, id_kp_clientAuth)), + new Extension(basicConstraints, true, notCa()), + new Extension(subjectKeyIdentifier, false, createSubjectKeyIdentifier(keyPair.getPublic())), + new Extension(authorityKeyIdentifier, false, createAuthorityKeyIdentifier(caCert.getPublicKey())) + ) + ); + } + + /** + * Sets issues DN + * + * @param issuerDn issues DN + * @return builder + */ + public CertAndKeyBuilder withIssuerDn(String issuerDn) { + this.issuer = new X500Name(issuerDn); + return this; + } + + /** + * Sets subject DN + * + * @param subjectDn subject DN + * @return builder + */ + public CertAndKeyBuilder withSubjectDn(String subjectDn) { + this.subject = new X500Name(subjectDn); + return this; + } + + /** + * Sets san dns + * + * @param hostName hostname + * @return builder + */ + public CertAndKeyBuilder withSanDnsName(final String hostName) { + final GeneralName dnsName = new GeneralName(dNSName, hostName); + final byte[] subjectAltName = encode(GeneralNames.getInstance(new DERSequence(dnsName))); + extensions.add(new Extension(subjectAlternativeName, false, subjectAltName)); + return this; + } + + /** + * Sets multiple san dns names + * + * @param sanDnsNames list of san dns names + * @return builder + */ + public CertAndKeyBuilder withSanDnsNames(final ASN1Encodable[] sanDnsNames) { + final DERSequence subjectAlternativeNames = new DERSequence(sanDnsNames); + final byte[] subjectAltName = encode(GeneralNames.getInstance(subjectAlternativeNames)); + extensions.add(new Extension(subjectAlternativeName, false, subjectAltName)); + + return this; + } + + /** + * Returns cert and key in memory from builder + * + * @return cert and key in memory + */ + public CertAndKey build() { + try { + BigInteger certSerialNumber = BigInteger.valueOf(System.currentTimeMillis()); + ContentSigner contentSigner = createContentSigner(); + Instant startDate = Instant.now().minus(1, DAYS); + Instant endDate = startDate.plus(CERTIFICATE_VALIDITY_PERIOD); + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, + certSerialNumber, + Date.from(startDate), + Date.from(endDate), + subject, + keyPair.getPublic() + ); + for (Extension extension : extensions) { + certBuilder.addExtension(extension); + } + X509Certificate certificate = new JcaX509CertificateConverter() + .setProvider(PROVIDER_NAME) + .getCertificate(certBuilder.build(contentSigner)); + return new CertAndKey(certificate, keyPair.getPrivate()); + } catch (CertIOException | CertificateException | OperatorCreationException e) { + throw new RuntimeException(e); + } + } + + private ContentSigner createContentSigner() throws OperatorCreationException { + JcaContentSignerBuilder builder = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM); + if (caCert == null) { + return builder.build(keyPair.getPrivate()); + } else { + return builder.build(caCert.getPrivateKey()); + } + } + + private static KeyPair generateKeyPair() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_PAIR_ALGORITHM, PROVIDER_NAME); + keyPairGenerator.initialize(KEY_SIZE); + return keyPairGenerator.generateKeyPair(); + } catch (NoSuchAlgorithmException | NoSuchProviderException e) { + throw new RuntimeException(e); + } + } + + private static byte[] keyUsage(int usage) { + return encode(new KeyUsage(usage)); + } + + private static byte[] extendedKeyUsage(KeyPurposeId... usages) { + return encode(new ExtendedKeyUsage(usages)); + } + + private static byte[] notCa() { + return encode(new BasicConstraints(false)); + } + + private static byte[] ca() { + return encode(new BasicConstraints(true)); + } + + private static byte[] createSubjectKeyIdentifier(PublicKey publicKey) { + JcaX509ExtensionUtils extensionUtils = createExtensionUtils(); + return encode(extensionUtils.createSubjectKeyIdentifier(publicKey)); + } + + private static byte[] createAuthorityKeyIdentifier(PublicKey publicKey) { + JcaX509ExtensionUtils extensionUtils = createExtensionUtils(); + return encode(extensionUtils.createAuthorityKeyIdentifier(publicKey)); + } + + private static byte[] encode(ASN1Object object) { + try { + return object.getEncoded(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static JcaX509ExtensionUtils createExtensionUtils() { + try { + return new JcaX509ExtensionUtils(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyFiles.java b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyFiles.java new file mode 100644 index 0000000..2dbeb12 --- /dev/null +++ b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyFiles.java @@ -0,0 +1,34 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import java.io.File; + +/** + * Record for cert and key files on disk + * + * @param certFile cert file + * @param keyFile key file + */ +public record CertAndKeyFiles(File certFile, File keyFile) { + + /** + * Returns path of cert file + * + * @return string path + */ + public String getCertPath() { + return certFile.getPath(); + } + + /** + * Returns path of key file + * + * @return string path + */ + public String getKeyPath() { + return keyFile.getPath(); + } +} diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/security/OpenSsl.java b/test-frame-common/src/main/java/io/skodjob/testframe/security/OpenSsl.java new file mode 100644 index 0000000..ee5da2d --- /dev/null +++ b/test-frame-common/src/main/java/io/skodjob/testframe/security/OpenSsl.java @@ -0,0 +1,235 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import io.skodjob.testframe.TestFrameConstants; +import io.skodjob.testframe.wait.Wait; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +/** + * The `OpenSsl` class encapsulates OpenSSL command execution using the OpenSSLCommand object, + * which interfaces with the command-line version of OpenSSL. It serves as a versatile tool + * for various OpenSSL operations, primarily focusing on the creation of private keys, the + * generation of certificate signing requests (CSRs), and the signing of these CSRs using + * a certificate authority (CA). The primary use case for this class is to facilitate the + * simulation of externally provided client certificates, offering a seamless solution for + * integrating secure authentication mechanisms into your application. + */ +public class OpenSsl { + private static final Logger LOGGER = LoggerFactory.getLogger(OpenSsl.class); + + private OpenSsl() { + // empty constructor + } + + private static class OpenSslCommand { + ProcessBuilder pb = new ProcessBuilder(); + + OpenSslCommand(String command) { + this("openssl", command); + } + + OpenSslCommand(String binary, String command) { + pb.command().add(binary); + pb.command().add(command); + } + + public OpenSslCommand withOption(String option) { + pb.command().add(option); + return this; + } + + public OpenSslCommand withOptionAndArgument(String option, File argument) { + pb.command().add(option); + pb.command().add(argument.getAbsolutePath()); + return this; + } + + public OpenSslCommand withOptionAndArgument(String option, String argument) { + pb.command().add(option); + pb.command().add(argument); + return this; + } + + public void execute() { + executeAndReturnOnSuccess(true); + } + + public String executeAndReturn() { + return executeAndReturnOnSuccess(true); + } + + public String executeAndReturnOnSuccess(boolean failOnNonZeroOutput) { + + Path commandOutput = null; + try { + commandOutput = Files.createTempFile("openssl-command-output-", ".txt"); + + pb.redirectErrorStream(true) + .redirectOutput(commandOutput.toFile()); + + LOGGER.debug("Running command: {}", pb.command()); + + Process process = pb.start(); + + OutputStream outputStream = process.getOutputStream(); + outputStream.close(); + + int exitCode = process.waitFor(); + String outputText = Files.readString(commandOutput, StandardCharsets.UTF_8); + + if (exitCode != 0 && failOnNonZeroOutput) { + throw new RuntimeException("Openssl command failed. " + outputText); + } + + return outputText; + } catch (InterruptedException | IOException e) { + throw new RuntimeException(e); + } finally { + removeFile(commandOutput); + } + } + + static void removeFile(Path fileToRemove) { + if (fileToRemove != null && Files.exists(fileToRemove)) { + try { + Files.delete(fileToRemove); + } catch (IOException e) { + LOGGER.debug("File could not be removed: {}", fileToRemove); + } + } + + } + } + + /** + * Generates private key + * + * @return file with private key + */ + public static File generatePrivateKey() { + return generatePrivateKey(2048); + } + + /** + * Generates private key + * + * @param keyLengthBits key length + * @return file with private key + */ + public static File generatePrivateKey(int keyLengthBits) { + try { + LOGGER.info("Creating client RSA private key with size of {} bits", keyLengthBits); + File privateKey = Files.createTempFile("private-key-", ".pem").toFile(); + + new OpenSslCommand("genpkey") + .withOptionAndArgument("-algorithm", "RSA") + .withOptionAndArgument("-pkeyopt", "rsa_keygen_bits:" + keyLengthBits) + .withOptionAndArgument("-out", privateKey) + .execute(); + + return privateKey; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Generates cert signing request + * + * @param privateKey file with private key + * @param subject subject + * @return csr file + */ + public static File generateCertSigningRequest(File privateKey, String subject) { + try { + LOGGER.info("Creating Certificate Signing Request file"); + File csr = Files.createTempFile("csr-", ".pem").toFile(); + + new OpenSslCommand("req") + .withOption("-new") + .withOptionAndArgument("-key", privateKey) + .withOptionAndArgument("-out", csr) + .withOptionAndArgument("-subj", subject) + .execute(); + + return csr; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Generates signed cert + * + * @param csr csr + * @param caCrt ca file + * @param caKey ca key + * @return file with signed cert + */ + public static File generateSignedCert(File csr, File caCrt, File caKey) { + try { + LOGGER.info("Creating signed certificate file"); + File cert = Files.createTempFile("signed-cert-", ".pem").toFile(); + + new OpenSslCommand("x509") + .withOption("-req") + .withOptionAndArgument("-in", csr) + .withOptionAndArgument("-CA", caCrt) + .withOptionAndArgument("-CAkey", caKey) + .withOptionAndArgument("-out", cert) + .withOption("-CAcreateserial") + .execute(); + + waitForCertIsInValidDateRange(cert); + + return cert; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Waits for cert is valid in current range + * + * @param certificate signed cert file + */ + public static void waitForCertIsInValidDateRange(File certificate) { + String dates = new OpenSslCommand("x509") + .withOption("-noout") + .withOption("-dates") + .withOptionAndArgument("-in", certificate) + .executeAndReturn() + .trim().replace("\s\s", "\s"); + + String startDate = dates.split("\n")[0].replace("notBefore=", ""); + String endDate = dates.split("\n")[1].replace("notAfter=", ""); + + ZoneId gmtZone = ZoneId.of("GMT"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d[d] HH:mm:ss yyyy z"); + ZonedDateTime notBefore = ZonedDateTime.of(LocalDateTime.parse(startDate, formatter), gmtZone); + ZonedDateTime notAfter = ZonedDateTime.of(LocalDateTime.parse(endDate, formatter), gmtZone); + + Wait.until("certificate to be in valid date range", + TestFrameConstants.POLL_INTERVAL_FOR_RESOURCE_READINESS, TestFrameConstants.GLOBAL_POLL_INTERVAL_LONG, + () -> { + ZonedDateTime now = ZonedDateTime.now(gmtZone); + return (now.isAfter(notBefore.plusSeconds(TestFrameConstants.CA_CERT_VALIDITY_DELAY)) + && now.isBefore(notAfter.minusSeconds(TestFrameConstants.CA_CERT_VALIDITY_DELAY))); + }); + } +} diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/utils/SecurityUtils.java b/test-frame-common/src/main/java/io/skodjob/testframe/utils/SecurityUtils.java new file mode 100644 index 0000000..69b55e3 --- /dev/null +++ b/test-frame-common/src/main/java/io/skodjob/testframe/utils/SecurityUtils.java @@ -0,0 +1,160 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.utils; + +import io.skodjob.testframe.security.CertAndKey; +import io.skodjob.testframe.security.CertAndKeyBuilder; +import io.skodjob.testframe.security.CertAndKeyFiles; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1Encoding; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.HashSet; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Utils for manipulating with certs + */ +public class SecurityUtils { + + private SecurityUtils() { + // empty constructor + } + + /** + * Export in-memory cert and key into pem files + * + * @param certs im memory certs + * @return exported files + */ + public static CertAndKeyFiles exportToPemFiles(CertAndKey... certs) { + if (certs.length == 0) { + throw new IllegalArgumentException("List of certificates should has at least one element"); + } + try { + File keyFile = exportPrivateKeyToPemFile(certs[0].getPrivateKey()); + File certFile = exportCertsToPemFile(certs); + return new CertAndKeyFiles(certFile, keyFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Converts private key into PKCS8File + * + * @param privatekey private key + * @return exported file on disk + * @throws NoSuchAlgorithmException bad algorithm + * @throws InvalidKeySpecException bad key + * @throws IOException io exception + */ + public static File convertPrivateKeyToPKCS8File(PrivateKey privatekey) + throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + byte[] encoded = privatekey.getEncoded(); + final PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(encoded); + + final ASN1Encodable asn1Encodable = privateKeyInfo.parsePrivateKey(); + final byte[] privateKeyPKCS8Formatted = asn1Encodable.toASN1Primitive().getEncoded(ASN1Encoding.DER); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyPKCS8Formatted); + + KeyFactory kf = KeyFactory.getInstance(CertAndKeyBuilder.KEY_PAIR_ALGORITHM); + PrivateKey privateKey = kf.generatePrivate(keySpec); + return exportPrivateKeyToPemFile(privateKey); + } + + /** + * Exports private key into pem file + * + * @param privateKey private key + * @return exported em file on disk + * @throws IOException io exception + */ + private static File exportPrivateKeyToPemFile(PrivateKey privateKey) throws IOException { + File keyFile = Files.createTempFile("key-", ".key").toFile(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter(keyFile, UTF_8))) { + pemWriter.writeObject(privateKey); + pemWriter.flush(); + } + return keyFile; + } + + /** + * Exports in memory certificates into single pem file + * + * @param certs certificates + * @return file with all certificates + * @throws IOException io exception + */ + private static File exportCertsToPemFile(CertAndKey... certs) throws IOException { + File certFile = Files.createTempFile("crt-", ".crt").toFile(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter(certFile, UTF_8))) { + for (CertAndKey certAndKey : certs) { + pemWriter.writeObject(certAndKey.getCertificate()); + } + pemWriter.flush(); + } + return certFile; + } + + /** + * This method exports Certificate Authority (CA) data to a temporary file for cases in which mentioned data is + * necessary in form of file - for use in applications like OpenSSL. The primary purpose is to save CA files, + * such as certificates and private keys (e.g., ca.key and ca.cert), into temporary files. + * These files are essential when you need to provide CA data to other applications, such as OpenSSL, + * for signing user Certificate Signing Requests (CSRs). + * + * @param caData The Certificate Authority data to be saved to the temporary file. + * @param prefix The prefix for the temporary file's name. + * @param suffix The suffix for the temporary file's name. + * @return A File object representing the temporary file containing the CA data. + * @throws RuntimeException If an IOException occurs while creating a file or writing into the temporary file + * given the critical role these operations play in ensuring proper functionality. + */ + public static File exportCaDataToFile(String caData, String prefix, String suffix) { + try { + File tempFile = Files.createTempFile(prefix + "-", suffix).toFile(); + + try (FileWriter fileWriter = new FileWriter(tempFile, StandardCharsets.UTF_8)) { + fileWriter.write(caData); + fileWriter.flush(); + } + + return tempFile; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Check if principal1 contains all dns of principal2 in dn + * + * @param principal1 principal + * @param principal2 principal + * @return true of principal 1 contains all dn of principal 2 + */ + public static boolean containsAllDN(String principal1, String principal2) { + try { + return new HashSet<>(new LdapName(principal1).getRdns()).containsAll(new LdapName(principal2).getRdns()); + } catch (InvalidNameException e) { + e.printStackTrace(); + } + return false; + } +} From eb4fb5dcee28dc3a1c582c8bf9f532b0f3b4100c Mon Sep 17 00:00:00 2001 From: David Kornel Date: Thu, 25 Jul 2024 16:08:52 +0200 Subject: [PATCH 2/3] Address javadoc comments Signed-off-by: David Kornel --- .../testframe/security/CertAndKeyBuilder.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java index 385d63a..11149f6 100644 --- a/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java +++ b/test-frame-common/src/main/java/io/skodjob/testframe/security/CertAndKeyBuilder.java @@ -110,7 +110,7 @@ private CertAndKeyBuilder(KeyPair keyPair, CertAndKey caCert, List ex /** * Returns builder for root CA * - * @return builder for root CA + * @return Returns builder for root CA */ public static CertAndKeyBuilder rootCaCertBuilder() { KeyPair keyPair = generateKeyPair(); @@ -129,7 +129,7 @@ public static CertAndKeyBuilder rootCaCertBuilder() { * Returns builder for intermediate CA * * @param caCert ca certificate - * @return builder for intermediate CA + * @return Returns builder for intermediate CA */ public static CertAndKeyBuilder intermediateCaCertBuilder(CertAndKey caCert) { KeyPair keyPair = generateKeyPair(); @@ -149,7 +149,7 @@ public static CertAndKeyBuilder intermediateCaCertBuilder(CertAndKey caCert) { * Returns builder for application cert * * @param caCert ca certificate - * @return builder for application cert + * @return Returns builder for application cert */ public static CertAndKeyBuilder appCaCertBuilder(CertAndKey caCert) { KeyPair keyPair = generateKeyPair(); @@ -165,10 +165,10 @@ public static CertAndKeyBuilder appCaCertBuilder(CertAndKey caCert) { } /** - * Returns builder for end end entity cert + * Returns builder for end entity cert * * @param caCert ca certificate - * @return Returns builder for end end entity cert + * @return Returns builder for end entity cert */ public static CertAndKeyBuilder endEntityCertBuilder(CertAndKey caCert) { KeyPair keyPair = generateKeyPair(); @@ -237,7 +237,7 @@ public CertAndKeyBuilder withSanDnsNames(final ASN1Encodable[] sanDnsNames) { /** * Returns cert and key in memory from builder * - * @return cert and key in memory + * @return Returns cert and key in memory */ public CertAndKey build() { try { From 88698d4ef435d7077e7da630b0fe949fced308f4 Mon Sep 17 00:00:00 2001 From: David Kornel Date: Thu, 25 Jul 2024 17:09:11 +0200 Subject: [PATCH 3/3] Add unit tests Signed-off-by: David Kornel --- .../TestEnvironmentVariablesTest.java | 3 +- .../security/CertAndKeyBuilderTest.java | 59 ++++++++++++++++ .../testframe/security/SecurityUtilsTest.java | 69 +++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) rename test-frame-common/src/test/java/io/skodjob/testframe/{clients => environment}/TestEnvironmentVariablesTest.java (94%) create mode 100644 test-frame-common/src/test/java/io/skodjob/testframe/security/CertAndKeyBuilderTest.java create mode 100644 test-frame-common/src/test/java/io/skodjob/testframe/security/SecurityUtilsTest.java diff --git a/test-frame-common/src/test/java/io/skodjob/testframe/clients/TestEnvironmentVariablesTest.java b/test-frame-common/src/test/java/io/skodjob/testframe/environment/TestEnvironmentVariablesTest.java similarity index 94% rename from test-frame-common/src/test/java/io/skodjob/testframe/clients/TestEnvironmentVariablesTest.java rename to test-frame-common/src/test/java/io/skodjob/testframe/environment/TestEnvironmentVariablesTest.java index 781906d..3891ac4 100644 --- a/test-frame-common/src/test/java/io/skodjob/testframe/clients/TestEnvironmentVariablesTest.java +++ b/test-frame-common/src/test/java/io/skodjob/testframe/environment/TestEnvironmentVariablesTest.java @@ -2,9 +2,8 @@ * Copyright Skodjob authors. * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ -package io.skodjob.testframe.clients; +package io.skodjob.testframe.environment; -import io.skodjob.testframe.environment.TestEnvironmentVariables; import org.junit.jupiter.api.Test; import java.io.IOException; diff --git a/test-frame-common/src/test/java/io/skodjob/testframe/security/CertAndKeyBuilderTest.java b/test-frame-common/src/test/java/io/skodjob/testframe/security/CertAndKeyBuilderTest.java new file mode 100644 index 0000000..76cbfdb --- /dev/null +++ b/test-frame-common/src/test/java/io/skodjob/testframe/security/CertAndKeyBuilderTest.java @@ -0,0 +1,59 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CertAndKeyBuilderTest { + static final String ROOT_CA = "C=COM, L=Boston, O=Example, CN=ExampleRootCA"; + static final String INTERMEDIATE_CA = "C=COM, L=Boston, O=Example, CN=ExampleIntermediateCA"; + static final String END_SUBJECT = "C=COM, L=Boston, O=Example, CN=end-app.example.io"; + static final String APP_SUBJECT = "C=COM, L=Boston, O=Example, CN=app.example.io"; + + static final String COMPARE_ROOT_DN = "CN=ExampleRootCA,O=Example,L=Boston,C=COM"; + static final String COMPARE_INTERMEDIATE_DN = "CN=ExampleIntermediateCA,O=Example,L=Boston,C=COM"; + + @Test + void testGenerateCerts() { + CertAndKey ca = CertAndKeyBuilder.rootCaCertBuilder() + .withIssuerDn(ROOT_CA) + .withSubjectDn(ROOT_CA) + .build(); + + assertEquals(COMPARE_ROOT_DN, ca.certificate().getIssuerX500Principal().getName()); + assertDoesNotThrow(() -> ca.certificate().checkValidity()); + + CertAndKey intermediateCa = CertAndKeyBuilder.intermediateCaCertBuilder(ca) + .withIssuerDn(INTERMEDIATE_CA) + .withSubjectDn(INTERMEDIATE_CA) + .build(); + + assertEquals(COMPARE_INTERMEDIATE_DN, intermediateCa.certificate().getIssuerX500Principal().getName()); + assertDoesNotThrow(() -> intermediateCa.certificate().checkValidity()); + + CertAndKey appCert = CertAndKeyBuilder.appCaCertBuilder(ca) + .withSubjectDn(APP_SUBJECT) + .build(); + + assertEquals(COMPARE_ROOT_DN, appCert.certificate().getIssuerX500Principal().getName()); + assertDoesNotThrow(() -> appCert.certificate().checkValidity()); + + CertAndKey endAppCert = CertAndKeyBuilder.endEntityCertBuilder(intermediateCa) + .withSubjectDn(END_SUBJECT) + .withSanDnsName("*.example.io") + .build(); + + assertEquals(COMPARE_INTERMEDIATE_DN, endAppCert.certificate().getIssuerX500Principal().getName()); + assertDoesNotThrow(() -> endAppCert.certificate().checkValidity()); + + // check cert signing + assertDoesNotThrow(() -> appCert.certificate().verify(ca.getPublicKey())); + assertDoesNotThrow(() -> endAppCert.certificate().verify(intermediateCa.getPublicKey())); + assertDoesNotThrow(() -> intermediateCa.certificate().verify(ca.getPublicKey())); + } +} diff --git a/test-frame-common/src/test/java/io/skodjob/testframe/security/SecurityUtilsTest.java b/test-frame-common/src/test/java/io/skodjob/testframe/security/SecurityUtilsTest.java new file mode 100644 index 0000000..41ae40f --- /dev/null +++ b/test-frame-common/src/test/java/io/skodjob/testframe/security/SecurityUtilsTest.java @@ -0,0 +1,69 @@ +/* + * Copyright Skodjob authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.skodjob.testframe.security; + +import io.skodjob.testframe.utils.SecurityUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.cert.CertificateEncodingException; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class SecurityUtilsTest { + static final String ROOT_CA = "C=COM, L=Boston, O=Example, CN=ExampleRootCA"; + static final String INTERMEDIATE_CA = "C=COM, L=Boston, O=Example, CN=ExampleIntermediateCA"; + static final String END_SUBJECT = "C=COM, L=Boston, O=Example, CN=end-app.example.io"; + static final String APP_SUBJECT = "C=COM, L=Boston, O=Example, CN=app.example.io"; + + CertAndKey ca; + CertAndKey intermediateCa; + CertAndKey appCert; + CertAndKey endAppCert; + + @BeforeAll + void setup() { + ca = CertAndKeyBuilder.rootCaCertBuilder() + .withIssuerDn(ROOT_CA) + .withSubjectDn(ROOT_CA) + .build(); + + intermediateCa = CertAndKeyBuilder.intermediateCaCertBuilder(ca) + .withIssuerDn(INTERMEDIATE_CA) + .withSubjectDn(INTERMEDIATE_CA) + .build(); + + appCert = CertAndKeyBuilder.appCaCertBuilder(ca) + .withSubjectDn(APP_SUBJECT) + .build(); + + endAppCert = CertAndKeyBuilder.endEntityCertBuilder(intermediateCa) + .withSubjectDn(END_SUBJECT) + .withSanDnsName("*.example.io") + .build(); + } + + @Test + void testExportCertsToPem() throws IOException, CertificateEncodingException { + CertAndKeyFiles all = SecurityUtils.exportToPemFiles(ca, intermediateCa, appCert); + + String content = Files.readString(Paths.get(all.getCertPath())); + assertNotEquals("", content); + } + + @Test + void testExportDataToCa() throws IOException { + File caCert = SecurityUtils.exportCaDataToFile(ca.getPublicKey().toString(), "ca", ".crt"); + + String content = Files.readString(caCert.toPath()); + assertNotEquals("", content); + } +}