diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java index 75aad4b..8d03d2d 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java @@ -26,7 +26,6 @@ public CertificateHolder(String cn, List sans, Duration duration, boolea KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - keys = keyPairGenerator.generateKeyPair(); certificate = CertificateUtils.generateCertificate(keys, cn, sans, duration); diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java index 4415548..0f0350c 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java @@ -28,7 +28,7 @@ import java.util.List; import java.util.Map; -class CertificateUtils { +public class CertificateUtils { static { Security.addProvider(new BouncyCastleProvider()); @@ -89,12 +89,17 @@ public static X509Certificate generateCertificate(KeyPair keyPair, String cn, Li return certGen.generate(keyPair.getPrivate()); } - public static void writeCertificateToPEM(X509Certificate certificate, File output) throws IOException, CertificateEncodingException { + public static void writeCertificateToPEM(X509Certificate certificate, File output, X509Certificate... chain) throws IOException, CertificateEncodingException { try (FileWriter fileWriter = new FileWriter(output); BufferedWriter pemWriter = new BufferedWriter(fileWriter)) { pemWriter.write("-----BEGIN CERTIFICATE-----\n"); pemWriter.write(Base64.getEncoder().encodeToString(certificate.getEncoded())); pemWriter.write("\n-----END CERTIFICATE-----\n\n"); + for (X509Certificate cert : chain) { + pemWriter.write("-----BEGIN CERTIFICATE-----\n"); + pemWriter.write(Base64.getEncoder().encodeToString(cert.getEncoded())); + pemWriter.write("\n-----END CERTIFICATE-----\n\n"); + } } } diff --git a/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java b/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java new file mode 100644 index 0000000..e0e76c4 --- /dev/null +++ b/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java @@ -0,0 +1,184 @@ +package me.escoffier.certs.chain; + +import me.escoffier.certs.CertificateUtils; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.*; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +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.File; +import java.math.BigInteger; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +public class CertificateChainGenerator { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private String cn = "localhost"; + + private List sans = List.of("DNS:localhost"); + + private File baseDir; // Mandatory + + public CertificateChainGenerator(File baseDir) { + this.baseDir = baseDir; + if (!baseDir.isDirectory()) { + baseDir.mkdirs(); + } + } + + /** + * Configure the common name of the "leaf" certificate. + * + * @param cn the common name, by default `localhost` + * @return the current generator instance + */ + public CertificateChainGenerator withCN(String cn) { + this.cn = cn; + return this; + } + + /** + * Configure the Subject Alternative Names of the "leaf" certificate. + * + * @param san the list of SAN, by default `DNS:localhost` + * @return the current generator instance + */ + public CertificateChainGenerator withSAN(List san) { + this.sans = san; + return this; + } + + public void generate() throws Exception { + + // Generate root certificate + var rootKeyPair = generateKeyPair(); + var rootCertificate = generateRootCertificate(rootKeyPair); + + // Generate intermediary certificate + var intermediaryKeyPair = generateKeyPair(); + var intermediaryCertificate = generateIntermediaryCertificate(intermediaryKeyPair, rootKeyPair, rootCertificate); + + // Generate leaf certificate + var leafKeyPair = generateKeyPair(); + var leafCertificate = generateLeafCertificate(leafKeyPair, intermediaryKeyPair, intermediaryCertificate); + + // Write the certificates to files + // root.crt, root.key, intermediary.crt, intermediary.key, cn.crt, cn.key + CertificateUtils.writeCertificateToPEM(rootCertificate, new File(baseDir, "root.crt")); + CertificateUtils.writePrivateKeyToPem(rootKeyPair.getPrivate(), new File(baseDir, "root.key")); + + CertificateUtils.writeCertificateToPEM(intermediaryCertificate, new File(baseDir, "intermediate.crt")); + CertificateUtils.writePrivateKeyToPem(intermediaryKeyPair.getPrivate(), new File(baseDir, "intermediate.key")); + + CertificateUtils.writeCertificateToPEM(leafCertificate, new File(baseDir, cn + ".crt"), intermediaryCertificate); + CertificateUtils.writePrivateKeyToPem(leafKeyPair.getPrivate(), new File(baseDir, cn + ".key")); + } + + private KeyPair generateKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } + + private X509Certificate generateRootCertificate(KeyPair rootKeyPair) throws CertIOException, NoSuchAlgorithmException, OperatorCreationException, CertificateException { + var keyInfo = SubjectPublicKeyInfo.getInstance(ASN1Sequence.getInstance(rootKeyPair.getPublic().getEncoded())); + var issuer = new X500Name("CN=quarkus-root,O=Quarkus Development"); + var subject = new X500Name("CN=root"); + var yesterday = new Date(System.currentTimeMillis() - 86400000); + var oneYear = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); // 1 year + X509v3CertificateBuilder certGen = new X509v3CertificateBuilder( + issuer, + BigInteger.valueOf(System.currentTimeMillis()), + yesterday, + oneYear, + subject, + keyInfo); + + certGen.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign)); + certGen.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + certGen.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(rootKeyPair.getPublic())); + + JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder("SHA256WithRSAEncryption"); + ContentSigner signer = contentSignerBuilder.build(rootKeyPair.getPrivate()); + X509CertificateHolder holder = certGen.build(signer); + return new JcaX509CertificateConverter().getCertificate(holder); + } + + private X509Certificate generateIntermediaryCertificate(KeyPair intermediaryKeyPair, KeyPair rootKeyPair, X509Certificate rootCertificate) throws NoSuchAlgorithmException, CertIOException, OperatorCreationException, CertificateException { + var keyInfo = SubjectPublicKeyInfo.getInstance(ASN1Sequence.getInstance(intermediaryKeyPair.getPublic().getEncoded())); + var yesterday = new Date(System.currentTimeMillis() - 86400000); + var oneYear = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); // 1 year + X509v3CertificateBuilder certGen = new X509v3CertificateBuilder( + new X500Name(rootCertificate.getSubjectX500Principal().getName()), + BigInteger.valueOf(System.currentTimeMillis()), + yesterday, + oneYear, + new X500Name("CN=intermediary"), + keyInfo + ); + + certGen.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.digitalSignature)); + certGen.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + certGen.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(intermediaryKeyPair.getPublic())); + + JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder("SHA256WithRSAEncryption"); + ContentSigner contentSigner = contentSignerBuilder.build(rootKeyPair.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(certGen.build(contentSigner)); + } + + private X509Certificate generateLeafCertificate(KeyPair leafKeyPair, KeyPair intermediaryKeyPair, X509Certificate intermediaryCertificate) throws NoSuchAlgorithmException, CertIOException, OperatorCreationException, CertificateException { + var keyInfo = SubjectPublicKeyInfo.getInstance(ASN1Sequence.getInstance(leafKeyPair.getPublic().getEncoded())); + var before = Instant.now().minus(2, ChronoUnit.DAYS); + var after = Instant.now().plus(2, ChronoUnit.DAYS); + + X509v3CertificateBuilder certGen = new X509v3CertificateBuilder( + new X500Name(intermediaryCertificate.getSubjectX500Principal().getName()), + BigInteger.valueOf(System.currentTimeMillis()), + new java.util.Date(before.toEpochMilli()), + new java.util.Date(after.toEpochMilli()), + new X500Name("CN=" + cn), + keyInfo + ); + + certGen.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.dataEncipherment | KeyUsage.keyAgreement | KeyUsage.nonRepudiation)); + certGen.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(leafKeyPair.getPublic())); + + DERSequence subjectAlternativeNames = + new DERSequence(sans.stream().map(s -> { + if (s.startsWith("DNS:")) { + return new GeneralName(GeneralName.dNSName, s.substring(4)); + } else if (s.startsWith("IP:")) { + return new GeneralName(GeneralName.iPAddress, s.substring(3)); + } else { + return new GeneralName(GeneralName.dNSName, s); + } + }).toArray(ASN1Encodable[]::new)); + certGen.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + + JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder("SHA256WithRSAEncryption"); + ContentSigner contentSigner = contentSignerBuilder.build(intermediaryKeyPair.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(certGen.build(contentSigner)); + } + + +} diff --git a/certificate-generator/src/test/java/me/escoffier/certs/VertxHttpHelper.java b/certificate-generator/src/test/java/me/escoffier/certs/VertxHttpHelper.java index 2a1260e..4140790 100644 --- a/certificate-generator/src/test/java/me/escoffier/certs/VertxHttpHelper.java +++ b/certificate-generator/src/test/java/me/escoffier/certs/VertxHttpHelper.java @@ -10,17 +10,30 @@ private VertxHttpHelper() { // Avoid direct instantiation } - static HttpServer createHttpServer(Vertx vertx, KeyCertOptions options) { + public static HttpServer createHttpServer(Vertx vertx, KeyCertOptions options) { return vertx.createHttpServer(new HttpServerOptions().setSsl(true).setKeyCertOptions(options)) .requestHandler(req -> req.response().end("OK")) .listen(0) .toCompletionStage().toCompletableFuture().join(); } - static HttpClientResponse createHttpClientAndInvoke(Vertx vertx, HttpServer server, TrustOptions options) { + public static HttpClientResponse createHttpClientAndInvoke(Vertx vertx, HttpServer server, TrustOptions options) { + return createHttpClientAndInvoke(vertx, server, options, true); + } + + public static HttpClientResponse createHttpClientAndInvoke(Vertx vertx, HttpServer server, TrustOptions options, boolean verifyHost) { + + if (options == null) { + return vertx.createHttpClient(new HttpClientOptions() + .setSsl(true).setDefaultHost("localhost").setDefaultPort(server.actualPort()).setVerifyHost(verifyHost) + ) + .request(HttpMethod.GET, "/").flatMap(HttpClientRequest::send).toCompletionStage().toCompletableFuture().join(); + } + return vertx.createHttpClient(new HttpClientOptions() .setSsl(true).setDefaultHost("localhost").setDefaultPort(server.actualPort()) - .setTrustOptions(options)) + .setTrustOptions(options) + .setVerifyHost(verifyHost)) .request(HttpMethod.GET, "/").flatMap(HttpClientRequest::send).toCompletionStage().toCompletableFuture().join(); } diff --git a/certificate-generator/src/test/java/me/escoffier/certs/chain/CertificateChainGeneratorTest.java b/certificate-generator/src/test/java/me/escoffier/certs/chain/CertificateChainGeneratorTest.java new file mode 100644 index 0000000..33d8d58 --- /dev/null +++ b/certificate-generator/src/test/java/me/escoffier/certs/chain/CertificateChainGeneratorTest.java @@ -0,0 +1,121 @@ +package me.escoffier.certs.chain; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpServer; +import io.vertx.core.net.PemKeyCertOptions; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.core.net.TrustOptions; +import me.escoffier.certs.VertxHttpHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLHandshakeException; +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CertificateChainGeneratorTest { + + private static Vertx vertx; + + @BeforeAll + static void initVertx() { + vertx = Vertx.vertx(); + } + + @AfterAll + static void closeVertx() { + vertx.close().toCompletionStage().toCompletableFuture().join(); + } + + + @Test + void testGenerateCertificateChainGeneration() throws Exception { + File dir = new File("target/chain"); + CertificateChainGenerator generator = new CertificateChainGenerator(dir) + .withCN("my-app"); + generator.generate(); + + // Verify files + File rootCertificate = new File(dir, "root.crt"); + File rootKey = new File(dir, "root.key"); + File intermediateCertificate = new File(dir, "intermediate.crt"); + File intermediateKey = new File(dir, "intermediate.key"); + File leafCertificate = new File(dir, "my-app.crt"); + File leafKey = new File(dir, "my-app.key"); + + assertThat(rootCertificate).isFile(); + assertThat(rootKey).isFile(); + assertThat(intermediateCertificate).isFile(); + assertThat(intermediateKey).isFile(); + assertThat(leafCertificate).isFile(); + assertThat(leafKey).isFile(); + + // Verify interactions + PemKeyCertOptions serverKS = new PemKeyCertOptions() + .setKeyPath(leafKey.getAbsolutePath()) + .setCertPath(leafCertificate.getAbsolutePath()); + + TrustOptions clientTS = new PemTrustOptions() + .addCertPath(rootCertificate.getAbsolutePath()); + + HttpServer server = VertxHttpHelper.createHttpServer(vertx, serverKS); + HttpClientResponse response = VertxHttpHelper.createHttpClientAndInvoke(vertx, server, clientTS); + assertThat(response.statusCode()).isEqualTo(200); + + } + + @Test + void testWithIntermediateInTS() throws Exception { + File dir = new File("target/chain"); + CertificateChainGenerator generator = new CertificateChainGenerator(dir) + .withCN("my-app"); + generator.generate(); + + File intermediateCertificate = new File(dir, "intermediate.crt"); + File leafCertificate = new File(dir, "my-app.crt"); + File leafKey = new File(dir, "my-app.key"); + + PemKeyCertOptions serverKS = new PemKeyCertOptions() + .setKeyPath(leafKey.getAbsolutePath()) + .setCertPath(leafCertificate.getAbsolutePath()); + + TrustOptions clientTS = new PemTrustOptions() + .addCertPath(intermediateCertificate.getAbsolutePath()); + + HttpServer server = VertxHttpHelper.createHttpServer(vertx, serverKS); + HttpClientResponse response = VertxHttpHelper.createHttpClientAndInvoke(vertx, server, clientTS); + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void testWithExposingTheIntermediate() throws Exception { + File dir = new File("target/chain"); + CertificateChainGenerator generator = new CertificateChainGenerator(dir) + .withCN("my-app"); + generator.generate(); + + File intermediateCertificate = new File(dir, "intermediate.crt"); + File intermediateKey = new File(dir, "intermediate.key"); + File rootCertificate = new File(dir, "root.crt"); + + + PemKeyCertOptions serverKS = new PemKeyCertOptions() + .setKeyPath(intermediateKey.getAbsolutePath()) + .setCertPath(intermediateCertificate.getAbsolutePath()); + + TrustOptions clientTS = new PemTrustOptions() + .addCertPath(rootCertificate.getAbsolutePath()); + + HttpServer server = VertxHttpHelper.createHttpServer(vertx, serverKS); + assertThatThrownBy(() -> VertxHttpHelper.createHttpClientAndInvoke(vertx, server, clientTS)) + .hasCauseInstanceOf(SSLHandshakeException.class); // The intermediate is trusted BUT the cn does not match + + var response = VertxHttpHelper.createHttpClientAndInvoke(vertx, server, clientTS, false); + assertThat(response.statusCode()).isEqualTo(200); + } + +} \ No newline at end of file