From b5550a3fdb8cbd19d91aefc1aaaf771e68f73068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Thu, 20 Jun 2019 14:15:55 +0200 Subject: [PATCH] Add utility methods to generate test certificates --- .../acme4j/util/CertificateUtils.java | 216 +++++++++++++++--- .../acme4j/util/CertificateUtilsTest.java | 98 ++++++++ 2 files changed, 286 insertions(+), 28 deletions(-) diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java index 66fdcb60..19a0da4e 100644 --- a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java @@ -19,7 +19,11 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; @@ -27,21 +31,31 @@ import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.Objects; +import java.util.function.Function; import javax.annotation.ParametersAreNonnullByDefault; import javax.annotation.WillClose; +import org.bouncycastle.asn1.ASN1Encodable; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.pkcs.Attribute; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509v1CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest; import org.shredzone.acme4j.Identifier; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; @@ -106,44 +120,190 @@ public final class CertificateUtils { } final long now = System.currentTimeMillis(); - final String signatureAlg = "SHA256withRSA"; + + X500Name issuer = new X500Name("CN=acme.invalid"); + BigInteger serial = BigInteger.valueOf(now); + Instant notBefore = Instant.ofEpochMilli(now); + Instant notAfter = notBefore.plus(Duration.ofDays(7)); + + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, Date.from(notBefore), Date.from(notAfter), + issuer, keypair.getPublic()); + + GeneralName[] gns = new GeneralName[1]; + + switch (id.getType()) { + case Identifier.TYPE_DNS: + gns[0] = new GeneralName(GeneralName.dNSName, id.getDomain()); + break; + + case Identifier.TYPE_IP: + gns[0] = new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress()); + break; + + default: + throw new IllegalArgumentException("Unsupported Identifier type " + id.getType()); + } + certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns)); + certBuilder.addExtension(ACME_VALIDATION, true, new DEROctetString(acmeValidation)); + + return buildCertificate(certBuilder::build, keypair.getPrivate()); + } + + /** + * Creates a self-signed root certificate. + *

+ * The generated certificate is only meant for testing purposes! + * + * @param subject + * This certificate's subject X.500 name. + * @param notBefore + * {@link Instant} before which the certificate is not valid. + * @param notAfter + * {@link Instant} after which the certificate is not valid. + * @param keypair + * {@link KeyPair} that is to be used for this certificate. + * @return Generated {@link X509Certificate} + * @since 2.8 + */ + public static X509Certificate createTestRootCertificate(String subject, + Instant notBefore, Instant notAfter, KeyPair keypair) { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(notBefore, "notBefore"); + Objects.requireNonNull(notAfter, "notAfter"); + Objects.requireNonNull(keypair, "keypair"); + + JcaX509v1CertificateBuilder certBuilder = new JcaX509v1CertificateBuilder( + new X500Name(subject), + BigInteger.valueOf(System.currentTimeMillis()), + Date.from(notBefore), + Date.from(notAfter), + new X500Name(subject), + keypair.getPublic() + ); + + return buildCertificate(certBuilder::build, keypair.getPrivate()); + } + + /** + * Creates an intermediate certificate that is signed by an issuer. + *

+ * The generated certificate is only meant for testing purposes! + * + * @param subject + * This certificate's subject X.500 name. + * @param notBefore + * {@link Instant} before which the certificate is not valid. + * @param notAfter + * {@link Instant} after which the certificate is not valid. + * @param intermediatePublicKey + * {@link PublicKey} of this certificate + * @param issuer + * The issuer's {@link X509Certificate}. + * @param issuerPrivateKey + * {@link PrivateKey} of the issuer. This is not the private key of this + * intermediate certificate. + * @return Generated {@link X509Certificate} + * @since 2.8 + */ + public static X509Certificate createTestIntermediateCertificate(String subject, + Instant notBefore, Instant notAfter, PublicKey intermediatePublicKey, + X509Certificate issuer, PrivateKey issuerPrivateKey) { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(notBefore, "notBefore"); + Objects.requireNonNull(notAfter, "notAfter"); + Objects.requireNonNull(intermediatePublicKey, "intermediatePublicKey"); + Objects.requireNonNull(issuer, "issuer"); + Objects.requireNonNull(issuerPrivateKey, "issuerPrivateKey"); + + JcaX509v1CertificateBuilder certBuilder = new JcaX509v1CertificateBuilder( + new X500Name(issuer.getIssuerX500Principal().getName()), + BigInteger.valueOf(System.currentTimeMillis()), + Date.from(notBefore), + Date.from(notAfter), + new X500Name(subject), + intermediatePublicKey + ); + + return buildCertificate(certBuilder::build, issuerPrivateKey); + } + + /** + * Creates a signed end entity certificate from the given CSR. + *

+ * This method is only meant for testing purposes! Do not use it in a real-world CA + * implementation. + *

+ * Do not assume that real-world certificates have a similar structure. It's up to the + * discretion of the CA which distinguished names, validity dates, extensions and + * other parameters are transferred from the CSR to the generated certificate. + * + * @param csr + * CSR to create the certificate from + * @param notBefore + * {@link Instant} before which the certificate is not valid. + * @param notAfter + * {@link Instant} after which the certificate is not valid. + * @param issuer + * The issuer's {@link X509Certificate}. + * @param issuerPrivateKey + * {@link PrivateKey} of the issuer. This is not the private key the CSR was + * signed with. + * @return Generated {@link X509Certificate} + * @since 2.8 + */ + public static X509Certificate createTestCertificate(PKCS10CertificationRequest csr, + Instant notBefore, Instant notAfter, X509Certificate issuer, PrivateKey issuerPrivateKey) { + Objects.requireNonNull(csr, "csr"); + Objects.requireNonNull(notBefore, "notBefore"); + Objects.requireNonNull(notAfter, "notAfter"); + Objects.requireNonNull(issuer, "issuer"); + Objects.requireNonNull(issuerPrivateKey, "issuerPrivateKey"); try { - X500Name issuer = new X500Name("CN=acme.invalid"); - BigInteger serial = BigInteger.valueOf(now); - Instant notBefore = Instant.ofEpochMilli(now); - Instant notAfter = notBefore.plus(Duration.ofDays(7)); + JcaPKCS10CertificationRequest jcaCsr = new JcaPKCS10CertificationRequest(csr); JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( - issuer, serial, Date.from(notBefore), Date.from(notAfter), - issuer, keypair.getPublic()); + new X500Name(issuer.getIssuerX500Principal().getName()), + BigInteger.valueOf(System.currentTimeMillis()), + Date.from(notBefore), + Date.from(notAfter), + csr.getSubject(), + jcaCsr.getPublicKey()); - GeneralName[] gns = new GeneralName[1]; - - switch (id.getType()) { - case Identifier.TYPE_DNS: - gns[0] = new GeneralName(GeneralName.dNSName, id.getDomain()); - break; - - case Identifier.TYPE_IP: - gns[0] = new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress()); - break; - - default: - throw new IllegalArgumentException("Unsupported Identifier type " + id.getType()); + Attribute[] attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest); + if (attr.length > 0) { + ASN1Encodable[] extensions = attr[0].getAttrValues().toArray(); + if (extensions.length > 0 && extensions[0] instanceof Extensions) { + GeneralNames san = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName); + certBuilder.addExtension(Extension.subjectAlternativeName, false, san); + } } - certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns)); - certBuilder.addExtension(ACME_VALIDATION, true, new DEROctetString(acmeValidation)); - - JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder(signatureAlg); - - byte[] cert = certBuilder.build(signerBuilder.build(keypair.getPrivate())).getEncoded(); + return buildCertificate(certBuilder::build, issuerPrivateKey); + } catch (NoSuchAlgorithmException | InvalidKeyException | CertIOException ex) { + throw new IllegalArgumentException("Invalid CSR", ex); + } + } + /** + * Build a {@link X509Certificate} from a builder. + * + * @param builder + * Builder method that receives a {@link ContentSigner} and returns a {@link + * X509CertificateHolder}. + * @param privateKey + * {@link PrivateKey} to sign the certificate with + * @return The generated {@link X509Certificate} + */ + private static X509Certificate buildCertificate(Function builder, PrivateKey privateKey) { + try { + JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder("SHA256withRSA"); + byte[] cert = builder.apply(signerBuilder.build(privateKey)).getEncoded(); CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(cert)); - } catch (CertificateException | OperatorCreationException ex) { - throw new IOException(ex); + } catch (CertificateException | OperatorCreationException | IOException ex) { + throw new IllegalArgumentException("Could not build certificate", ex); } } diff --git a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java index 9d49867e..9d43e9e0 100644 --- a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java +++ b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java @@ -13,6 +13,7 @@ */ package org.shredzone.acme4j.util; +import static java.time.temporal.ChronoUnit.SECONDS; import static org.hamcrest.Matchers.*; import static org.junit.Assert.assertThat; @@ -25,6 +26,8 @@ import java.lang.reflect.Modifier; import java.net.InetAddress; import java.net.UnknownHostException; import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.time.Duration; @@ -142,6 +145,101 @@ public class CertificateUtilsTest { assertThat(getIpSANs(cert), contains(subject)); } + /** + * Test if {@link CertificateUtils#createTestRootCertificate(String, Instant, Instant, + * KeyPair)} generates a valid root certificate. + */ + @Test + public void testCreateTestRootCertificate() throws Exception { + KeyPair keypair = KeyPairUtils.createKeyPair(2048); + String subject = "CN=Test Root Certificate"; + Instant notBefore = Instant.now().truncatedTo(SECONDS); + Instant notAfter = notBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS); + + X509Certificate cert = CertificateUtils.createTestRootCertificate(subject, + notBefore, notAfter, keypair); + + assertThat(cert.getIssuerX500Principal().getName(), is(subject)); + assertThat(cert.getSubjectX500Principal().getName(), is(subject)); + assertThat(cert.getNotBefore().toInstant(), is(notBefore)); + assertThat(cert.getNotAfter().toInstant(), is(notAfter)); + assertThat(cert.getSerialNumber(), not(nullValue())); + assertThat(cert.getPublicKey(), is(keypair.getPublic())); + cert.verify(cert.getPublicKey()); // self-signed + } + + /** + * Test if {@link CertificateUtils#createTestIntermediateCertificate(String, Instant, + * Instant, PublicKey, X509Certificate, PrivateKey)} generates a valid intermediate + * certificate. + */ + @Test + public void testCreateTestIntermediateCertificate() throws Exception { + KeyPair rootKeypair = KeyPairUtils.createKeyPair(2048); + String rootSubject = "CN=Test Root Certificate"; + Instant rootNotBefore = Instant.now().minus(Duration.ofDays(1)).truncatedTo(SECONDS); + Instant rootNotAfter = rootNotBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS); + + X509Certificate rootCert = CertificateUtils.createTestRootCertificate(rootSubject, + rootNotBefore, rootNotAfter, rootKeypair); + + KeyPair keypair = KeyPairUtils.createKeyPair(2048); + String subject = "CN=Test Intermediate Certificate"; + Instant notBefore = Instant.now().truncatedTo(SECONDS); + Instant notAfter = notBefore.plus(Duration.ofDays(7)).truncatedTo(SECONDS); + + X509Certificate cert = CertificateUtils.createTestIntermediateCertificate(subject, + notBefore, notAfter, keypair.getPublic(), rootCert, rootKeypair.getPrivate()); + + assertThat(cert.getIssuerX500Principal().getName(), is(rootSubject)); + assertThat(cert.getSubjectX500Principal().getName(), is(subject)); + assertThat(cert.getNotBefore().toInstant(), is(notBefore)); + assertThat(cert.getNotAfter().toInstant(), is(notAfter)); + assertThat(cert.getSerialNumber(), not(nullValue())); + assertThat(cert.getSerialNumber(), not(rootCert.getSerialNumber())); + assertThat(cert.getPublicKey(), is(keypair.getPublic())); + cert.verify(rootKeypair.getPublic()); // signed by root + } + + /** + * Test if {@link CertificateUtils#createTestCertificate(PKCS10CertificationRequest, + * Instant, Instant, X509Certificate, PrivateKey)} generates a valid certificate. + */ + @Test + public void testCreateTestCertificate() throws Exception { + KeyPair rootKeypair = KeyPairUtils.createKeyPair(2048); + String rootSubject = "CN=Test Root Certificate"; + Instant rootNotBefore = Instant.now().minus(Duration.ofDays(1)).truncatedTo(SECONDS); + Instant rootNotAfter = rootNotBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS); + + X509Certificate rootCert = CertificateUtils.createTestRootCertificate(rootSubject, + rootNotBefore, rootNotAfter, rootKeypair); + + KeyPair keypair = KeyPairUtils.createKeyPair(2048); + Instant notBefore = Instant.now().truncatedTo(SECONDS); + Instant notAfter = notBefore.plus(Duration.ofDays(7)).truncatedTo(SECONDS); + + CSRBuilder builder = new CSRBuilder(); + builder.addDomains("example.org", "www.example.org"); + builder.addIP(InetAddress.getByName("192.168.0.1")); + builder.sign(keypair); + PKCS10CertificationRequest csr = builder.getCSR(); + + X509Certificate cert = CertificateUtils.createTestCertificate(csr, notBefore, + notAfter, rootCert, rootKeypair.getPrivate()); + + assertThat(cert.getIssuerX500Principal().getName(), is(rootSubject)); + assertThat(cert.getSubjectX500Principal().getName(), is("CN=example.org")); + assertThat(getSANs(cert), contains("example.org", "www.example.org")); + assertThat(getIpSANs(cert), contains(InetAddress.getByName("192.168.0.1"))); + assertThat(cert.getNotBefore().toInstant(), is(notBefore)); + assertThat(cert.getNotAfter().toInstant(), is(notAfter)); + assertThat(cert.getSerialNumber(), not(nullValue())); + assertThat(cert.getSerialNumber(), not(rootCert.getSerialNumber())); + assertThat(cert.getPublicKey(), is(keypair.getPublic())); + cert.verify(rootKeypair.getPublic()); // signed by root + } + /** * Extracts all DNSName SANs from a certificate. *