diff --git a/src/main/java/org/cryptacular/util/CertUtil.java b/src/main/java/org/cryptacular/util/CertUtil.java index b5d75ef3..5f707979 100644 --- a/src/main/java/org/cryptacular/util/CertUtil.java +++ b/src/main/java/org/cryptacular/util/CertUtil.java @@ -5,23 +5,45 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.security.KeyPair; import java.security.PrivateKey; import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Date; import java.util.List; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.RFC4519Style; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.GeneralNamesBuilder; import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x509.PolicyInformation; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +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 org.cryptacular.EncodingException; import org.cryptacular.StreamException; +import org.cryptacular.codec.Base64Encoder; import org.cryptacular.x509.ExtensionReader; import org.cryptacular.x509.GeneralNameType; import org.cryptacular.x509.KeyUsageBits; @@ -454,4 +476,237 @@ public static String authorityKeyId(final X509Certificate cert) throws EncodingE { return CodecUtil.hex(new ExtensionReader(cert).readAuthorityKeyIdentifier().getKeyIdentifier(), true); } + + + /** + * PEM encodes the given certificate with the provided encoding type. + * + * @param type of encoding + * + * @param certificate X.509 certificate. + * @param encodeType Type of encoding. {@link EncodeType#X509} or {@link EncodeType#PKCS7} + * + * @return either DER encoded certificate or PEM-encoded certificate header and footer defined by {@link EncodeType} + * and data wrapped at 64 characters per line. + * + * @throws RuntimeException if a certificate encoding error occurs + */ + public static T encodeCert(final X509Certificate certificate, final EncodeType encodeType) + { + try { + return encodeType.encode(certificate); + } catch (CertificateEncodingException e) { + throw new RuntimeException("Error getting encoded X.509 certificate data", e); + } + } + + /** + * Retrieves the subject distinguished name (DN) of the provided X.509 certificate. + * + * The subject DN represents the identity of the certificate holder and typically includes information + * such as the common name (CN), organizational unit (OU), organization (O), locality (L), state (ST), + * country (C), and other attributes. + * + * @param cert The X.509 certificate from which to extract the subject DN. + * @param format Controls whether the output contains spaces between attributes in the DN. + * Use {@link X500PrincipalFormat#READABLE} to generate a DN with spaces after the commas separating + * attribute-value pairs, {@link X500PrincipalFormat#RFC2253} for no spaces. + * @return The subject DN string of the X.509 certificate. + * + * @throws NullPointerException If the provided certificate is null. + */ + public static String subjectDN(final X509Certificate cert, final X500PrincipalFormat format) + { + final X500Principal subjectX500Principal = cert.getSubjectX500Principal(); + return X500PrincipalFormat.READABLE.equals(format) ? + subjectX500Principal.toString() : subjectX500Principal.getName(X500Principal.RFC2253); + } + + /** + * Generates a self-signed certificate. + * + * @param keyPair used for signing the certificate + * @param dn Subject dn + * @param duration Validity period of the certificate. The notAfter field is set to {@code now} + * plus this value. + * @param signatureAlgo the signature algorithm identifier to use + * + * @return a self-signed X509Certificate + */ + public static X509Certificate generateX509Certificate(final KeyPair keyPair, final String dn, + final Duration duration, final String signatureAlgo) + { + final Instant now = Instant.now(); + final Date notBefore = Date.from(now); + final Date notAfter = Date.from(now.plus(duration)); + return generateX509Certificate(keyPair, dn, notBefore, notAfter, signatureAlgo); + } + + /** + * Generates a self-signed certificate. + * + * @param keyPair used for signing the certificate + * @param dn Subject dn + * @param notBefore the date and time when the certificate validity period starts + * @param notAfter the date and time when the certificate validity period ends + * @param signatureAlgo the signature algorithm identifier to use + * + * @return a self-signed X509Certificate + */ + public static X509Certificate generateX509Certificate(final KeyPair keyPair, final String dn, + final Date notBefore, final Date notAfter, final String signatureAlgo) + { + final Instant now = Instant.now(); + final BigInteger serial = BigInteger.valueOf(now.toEpochMilli()); + + try { + final ContentSigner contentSigner = new JcaContentSignerBuilder(signatureAlgo) + .build(keyPair.getPrivate()); + final X500Name x500Name = new X500Name(RFC4519Style.INSTANCE, dn); + final X509v3CertificateBuilder certificateBuilder = + new JcaX509v3CertificateBuilder(x500Name, + serial, + notBefore, + notAfter, + x500Name, + keyPair.getPublic()) + .addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + + return new JcaX509CertificateConverter() + .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner)); + } catch (OperatorCreationException | CertIOException | CertificateException e) { + throw new RuntimeException("Certificate generation error", e); + } + } + + /** + * Describes the behavior of string formatting of X.500 distinguished names. + */ + public enum X500PrincipalFormat + { + /** The format described in RFC2253 (without spaces). */ + RFC2253, + + /** Similar to RFC2253, but with spaces. */ + READABLE + } + + /** + * Marker interface for encoding types. + * + * @param type of encoding + */ + public interface EncodeType + { + + /** DER encode type.*/ + EncodeType DER = new DEREncodeType(); + + /** X509 encode type. */ + EncodeType X509 = new X509EncodeType(); + + /** PKCS7 encode type. */ + EncodeType PKCS7 = new PKCS7EncodeType(); + + /** + * Returns the type of encoding. + * + * @return type + */ + String getType(); + + /** + * Encodes the supplied certificate. + * + * @param cert to encode + * + * @return encoded certificate + * + * @throws CertificateEncodingException if an error occurs encoding the certificate + */ + T encode(X509Certificate cert) throws CertificateEncodingException; + } + + /** + * Base implementation for PEM encoded types. + */ + private abstract static class AbstractPemEncodeType implements EncodeType + { + + /** + * Returns a PEM encoding of the supplied DER bytes. + * + * @param der to encode + * + * @return PEM encoded certificate + */ + protected String encodePem(final byte[] der) + { + final Base64Encoder encoder = new Base64Encoder(64); + final ByteBuffer input = ByteBuffer.wrap(der); + // Space for Base64-encoded data + header, footer, line breaks, and potential padding + final CharBuffer output = CharBuffer.allocate(encoder.outputSize(der.length) + 100); + output.append("-----BEGIN ").append(getType()).append("-----"); + output.append(System.lineSeparator()); + encoder.encode(input, output); + encoder.finalize(output); + output.flip(); + return output.toString().trim() + .concat(System.lineSeparator()).concat("-----END ").concat(getType()).concat("-----"); + } + } + + /** DER encode type. */ + private static class DEREncodeType implements EncodeType + { + + @Override + public String getType() + { + return "DER"; + } + + @Override + public byte[] encode(final X509Certificate cert) + throws CertificateEncodingException + { + return cert.getEncoded(); + } + } + + /** X509 encode type. */ + private static final class X509EncodeType extends AbstractPemEncodeType + { + + @Override + public String getType() + { + return "CERTIFICATE"; + } + + @Override + public String encode(final X509Certificate cert) + throws CertificateEncodingException + { + return encodePem(cert.getEncoded()); + } + } + + /** PKCS7 encode type. */ + private static final class PKCS7EncodeType extends AbstractPemEncodeType + { + + @Override + public String getType() + { + return "PKCS7"; + } + + @Override + public String encode(final X509Certificate cert) + throws CertificateEncodingException + { + return encodePem(cert.getEncoded()); + } + } } diff --git a/src/test/java/org/cryptacular/util/CertUtilTest.java b/src/test/java/org/cryptacular/util/CertUtilTest.java index bd7d8b22..1978c0d9 100644 --- a/src/test/java/org/cryptacular/util/CertUtilTest.java +++ b/src/test/java/org/cryptacular/util/CertUtilTest.java @@ -1,12 +1,22 @@ /* See LICENSE for licensing and NOTICE for copyright. */ package org.cryptacular.util; +import java.io.File; +import java.nio.file.Files; +import java.security.KeyPair; import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Date; import java.util.List; import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.KeyPurposeId; import org.cryptacular.FailListener; +import org.cryptacular.generator.KeyPairGenerator; import org.cryptacular.x509.GeneralNameType; import org.cryptacular.x509.KeyUsageBits; import org.testng.annotations.DataProvider; @@ -38,6 +48,69 @@ public Object[][] getSubjectCommonNames() }; } + @DataProvider(name = "subject-dn") + public Object[][] getSubjectDN() + { + return + new Object[][] { + new Object[] { + CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"), + "C=US,DC=edu,DC=vt,ST=Virginia,L=Blacksburg,O=Virginia Polytechnic Institute and State University," + + "OU=Middleware-Server-with-saltr,OU=Middleware Services,CN=ed.middleware.vt.edu", + }, + }; + } + + @DataProvider(name = "subject-dn-spaces") + public Object[][] getSubjectDNWithSpaces() + { + return + new Object[][] { + new Object[] { + CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"), + "C=US, DC=edu, DC=vt, ST=Virginia, L=Blacksburg, O=Virginia Polytechnic Institute and State University, " + + "OU=Middleware-Server-with-saltr, OU=Middleware Services, CN=ed.middleware.vt.edu", + }, + }; + } + + + @DataProvider(name = "encode-cert-p7") + public Object[][] getP7EncodedCert() throws Exception + { + return + new Object[][] { + new Object[] { + CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"), + new String(Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.p7b").toPath())), + }, + }; + } + + @DataProvider(name = "encode-cert-x509") + public Object[][] getX509Cert() throws Exception + { + return + new Object[][] { + new Object[] { + CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"), + new String(Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.crt").toPath())), + }, + }; + } + + @DataProvider(name = "encode-cert-der") + public Object[][] getDERCert() throws Exception + { + return + new Object[][] { + new Object[] { + CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"), + Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.der").toPath()), + }, + }; + } + @DataProvider(name = "subject-alt-names") public Object[][] getSubjectAltNames() { @@ -351,4 +424,80 @@ public void testReadCertificateChains(final String path, final int expectedCount { assertEquals(CertUtil.readCertificateChain(path).length, expectedCount); } + + @Test(dataProvider = "encode-cert-p7") + public void certEncodedAsPkcs7(final X509Certificate certificate, final String expectedEncodedCert) + { + final String actualEncodedCertString = CertUtil.encodeCert(certificate, CertUtil.EncodeType.PKCS7); + final X509Certificate decodedCert = CertUtil.decodeCertificate(CertUtil.encodeCert(certificate, + CertUtil.EncodeType.PKCS7).getBytes()); + assertEquals(actualEncodedCertString, expectedEncodedCert); + assertEquals(certificate, decodedCert); + } + + @Test(dataProvider = "encode-cert-x509") + public void certEncodedAsX509(final X509Certificate certificate, final String x509Cert) + { + final String encodedCert = CertUtil.encodeCert(certificate, CertUtil.EncodeType.X509); + assertEquals(encodedCert, x509Cert); + } + + @Test(dataProvider = "encode-cert-der") + public void certEncodedAsDER(final X509Certificate certificate, final byte[] derCert) + { + final byte[] encodedCert = CertUtil.encodeCert(certificate, CertUtil.EncodeType.DER); + assertEquals(encodedCert, derCert); + } + + @Test(dataProvider = "subject-dn") + public void testSubjectDN(final X509Certificate certificate, final String expectedResponse) + { + assertEquals(CertUtil.subjectDN(certificate, CertUtil.X500PrincipalFormat.RFC2253), expectedResponse); + } + + @Test(dataProvider = "subject-dn-spaces") + public void testSubjectDNWithSpaces(final X509Certificate certificate, final String expectedResponse) + { + assertEquals(CertUtil.subjectDN(certificate, CertUtil.X500PrincipalFormat.READABLE), expectedResponse); + } + + @Test + public void testGenX509() + { + final KeyPair keyPair = KeyPairGenerator.generateRSA(new SecureRandom(), 2048); + final String dn = "C=US, DC=edu, DC=vt, ST=Virginia, " + + "L=Blacksburg, O=Virginia Polytechnic Institute and State University, OU=Middleware-Server-with-saltr, " + + "OU=Middleware Services, CN=ed.middleware.vt.edu"; + + final Instant expectedNotBefore = Instant.now(); + final Instant expectedNotAfter = Instant.now().plus(Duration.ofDays(365)); + + final X509Certificate x509Certificate = CertUtil.generateX509Certificate(keyPair, dn, + Date.from(expectedNotBefore), Date.from(expectedNotAfter), "SHA256WithRSA"); + + assertEquals(truncateToSeconds(expectedNotBefore), truncateToSeconds(x509Certificate.getNotBefore().toInstant())); + assertEquals(truncateToSeconds(expectedNotAfter), truncateToSeconds(x509Certificate.getNotAfter().toInstant())); + } + + @Test(expectedExceptions = RuntimeException.class, + expectedExceptionsMessageRegExp = "Unknown signature type requested: UNSUPPORTEDALGO") + public void testGenX509UnSupportedAlgo() + { + final KeyPair keyPair = KeyPairGenerator.generateRSA(new SecureRandom(), 2048); + final String dn = "C=US, DC=edu, DC=vt, ST=Virginia, " + + "L=Blacksburg, O=Virginia Polytechnic Institute and State University, OU=Middleware-Server-with-saltr, " + + "OU=Middleware Services, CN=ed.middleware.vt.edu"; + + final Instant expectedNotBefore = Instant.now(); + final Instant expectedNotAfter = Instant.now().plus(Duration.ofDays(365)); + + CertUtil.generateX509Certificate(keyPair, dn, + Date.from(expectedNotBefore), Date.from(expectedNotAfter), "UNSUPPORTEDALGO"); + } + + + private OffsetDateTime truncateToSeconds(final Instant instant) + { + return instant.atOffset(ZoneOffset.UTC).withNano(0); + } } diff --git a/src/test/resources/certs/ed.middleware.vt.edu.der b/src/test/resources/certs/ed.middleware.vt.edu.der new file mode 100644 index 00000000..eec76f35 Binary files /dev/null and b/src/test/resources/certs/ed.middleware.vt.edu.der differ diff --git a/src/test/resources/certs/ed.middleware.vt.edu.p7b b/src/test/resources/certs/ed.middleware.vt.edu.p7b new file mode 100644 index 00000000..074bc4b7 --- /dev/null +++ b/src/test/resources/certs/ed.middleware.vt.edu.p7b @@ -0,0 +1,42 @@ +-----BEGIN PKCS7----- +MIIHTjCCBTagAwIBAgIITROLEwW3lyYwDQYJKoZIhvcNAQEFBQAwgZoxEzARBgoJ +kiaJk/IsZAEZEwNlZHUxEjAQBgoJkiaJk/IsZAEZEwJ2dDELMAkGA1UEBhMCVVMx +PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh +dGUgVW5pdmVyc2l0eTEkMCIGA1UEAxMbVmlyZ2luaWEgVGVjaCBNaWRkbGV3YXJl +IENBMB4XDTEyMDUyNTE2NTMxN1oXDTE0MDUyNTE2NTMxN1owggEAMR0wGwYDVQQD +DBRlZC5taWRkbGV3YXJlLnZ0LmVkdTEcMBoGA1UECwwTTWlkZGxld2FyZSBTZXJ2 +aWNlczElMCMGA1UECwwcTWlkZGxld2FyZS1TZXJ2ZXItd2l0aC1zYWx0cjE8MDoG +A1UECgwzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0ZSBV +bml2ZXJzaXR5MRMwEQYDVQQHDApCbGFja3NidXJnMREwDwYDVQQIDAhWaXJnaW5p +YTESMBAGCgmSJomT8ixkARkWAnZ0MRMwEQYKCZImiZPyLGQBGRYDZWR1MQswCQYD +VQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN2+UJtj+Bhd +um+GQe9zciP6nZKBgNWDLPLHo2s+beL0R9n9qIVapqUaB6Rqssgaf0K3WegX+ULR +9m6Dz0ljh9FOgfOtzDrPTAtqkGvz+HQZtKaAZzHW4WFAtjUrhhY1JI4zIvJiEkSg +db26IhSqb0+CB15XPZDE+ra5j4QlygOqfUs8fF+SrjkynAQ1ASWsnT+aHplg2nEY +VJPaN2LhxWjr6Uo2iVCuzc1vR3Tua/veZACztb2x8QK6sgUcsrH/OY02yFcJ7qyo +NhgxnMJSpLB/Mo6BLFjMn2fetePt96RgsO4FDwzDT7h1iGf5vXBBiId9QDk7fvZw +Z9wcJkLa1R0CAwEAAaOCAi0wggIpMB0GA1UdDgQWBBSrVN4+XHdiFIwgwiAFIWRX +UFRxPzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFP02QCKa1fxb8ATJkOZPT4hLRPhO +MHgGA1UdIARxMG8wDgYMKwYBBAG0aAUCAgIBMA4GDCsGAQQBtGgFAgIBATA9Bgwr +BgEEAbRoBQICBAEwLTArBggrBgEFBQcCARYfaHR0cDovL3d3dy5wa2kudnQuZWR1 +L3Z0bXcvY3BzLzAOBgwrBgEEAbRoBQICAwEwgdIGA1UdHwSByjCBxzCBxKCBwaCB +voaBu2h0dHA6Ly92dGNhLXAuZXByb3Yuc2V0aS52dC5lZHU6ODA4MC9lamJjYS9w +dWJsaWN3ZWIvd2ViZGlzdC9jZXJ0ZGlzdD9jbWQ9Y3JsJmlzc3Vlcj1DTj1WaXJn +aW5pYStUZWNoK01pZGRsZXdhcmUrQ0EsTz1WaXJnaW5pYStQb2x5dGVjaG5pYytJ +bnN0aXR1dGUrYW5kK1N0YXRlK1VuaXZlcnNpdHksREM9dnQsREM9ZWR1LEM9VVMw +CwYDVR0PBAQDAgTwMBMGA1UdJQQMMAoGCCsGAQUFBwMBMGsGA1UdEQRkMGKCFGVk +Lm1pZGRsZXdhcmUudnQuZWR1ghBkaXJlY3RvcnkudnQuZWR1ghNpZC5kaXJlY3Rv +cnkudnQuZWR1ghZhdXRobi5kaXJlY3RvcnkudnQuZWR1ggtsZGFwLnZ0LmVkdTAN +BgkqhkiG9w0BAQUFAAOCAgEABs6vLEbm28l3tpZOy1iWJEZbaXsKwVdMZXQKlQWx +QzXNe99ktfzsq1Rf99YhNefcxpwqIb4TKxc9e8hjSH39ySLso3PpjjywSjZzFkVn +I/gC+R8Lq6tgFJnEGeRfu6z699ej7YaX21SKqy4+qh+pXTKV9yppch8Wiz440Pbx +qTg0/422nFjBoDcfSby9g4GaFcQKsx26MZ3cY+bFZUSVZ9skFlv8hXQ60NnHL23L +TzYTje+/7gZAYMRWKQpbBvXGj/cu7cpX56qYNRV9My+xmDxkhv2aJaEyY6kePIY4 +4Ziu8PUfSLLK7CZexOyNTbVbbyL31s7ao07v1iXiY8rIJieCOrzEpqMHZ7qaGZVJ +26WL4fgVfGQu8mIWnlcE7R2sa6RtmLBqAHttXozYCuGVxMYYsyoFLg+I8p1wnFAF +4dS6rARiI/dbOrz5z8UokDDaAAfP8FfpyTBzi0mZo85XPZeB2Vy0zQjiZFAuzq8f +9XShx3VnLNz7FIvy2wNQwmLM3LXijL4NzCKo5GAE9KniFr4x9d+iAdzcOyZe5a9i +6KtfZD+9Z7HyV45gzKWR6WmNL9lS44afYNW1dTxQCY7dOo+27nW3eS59rcxHlifc +maJA9izvFEMxGDBzu5isUWyj+qp7VFpAm63uHKwy8+wmemm7C8xF04f+QnHpw5MM +Bl0= +-----END PKCS7----- \ No newline at end of file