diff --git a/certificate-generator/pom.xml b/certificate-generator/pom.xml index 5a63941..0a212b6 100644 --- a/certificate-generator/pom.xml +++ b/certificate-generator/pom.xml @@ -25,6 +25,12 @@ smallrye-common-os 2.8.0 + + io.smallrye.certs + private-key-pem-parser + ${project.version} + test + com.googlecode.plist diff --git a/certificate-generator/src/main/java/io/smallrye/certs/CertificateRequestManager.java b/certificate-generator/src/main/java/io/smallrye/certs/CertificateRequestManager.java index 80bb24c..2dbc04a 100644 --- a/certificate-generator/src/main/java/io/smallrye/certs/CertificateRequestManager.java +++ b/certificate-generator/src/main/java/io/smallrye/certs/CertificateRequestManager.java @@ -167,7 +167,7 @@ private CertificateFiles writePem(String name, CertificateHolder holder, Path ro CertificateUtils.writeCertificateToPEM(serverCert, certFile); } if (replaceIfExists || !keyFile.isFile()) { - CertificateUtils.writePrivateKeyToPem(serverKey.getPrivate(), keyFile); + CertificateUtils.writePrivateKeyToPem(serverKey.getPrivate(), request.getPassword(), keyFile); } if (replaceIfExists || !clientTrustFile.isFile()) { writeTruststoreToPem(List.of(serverCert), clientTrustFile); @@ -178,7 +178,7 @@ private CertificateFiles writePem(String name, CertificateHolder holder, Path ro CertificateUtils.writeCertificateToPEM(clientCert, clientCertFile); } if (replaceIfExists || !clientKeyFile.isFile()) { - CertificateUtils.writePrivateKeyToPem(clientKey.getPrivate(), clientKeyFile); + CertificateUtils.writePrivateKeyToPem(clientKey.getPrivate(), request.getPassword(), clientKeyFile); } if (replaceIfExists || !serverTrustfile.isFile()) { writeTruststoreToPem(List.of(clientCert), serverTrustfile); diff --git a/certificate-generator/src/main/java/io/smallrye/certs/CertificateUtils.java b/certificate-generator/src/main/java/io/smallrye/certs/CertificateUtils.java index cc73fd9..03a90b3 100644 --- a/certificate-generator/src/main/java/io/smallrye/certs/CertificateUtils.java +++ b/certificate-generator/src/main/java/io/smallrye/certs/CertificateUtils.java @@ -14,11 +14,13 @@ import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.Security; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -27,6 +29,12 @@ import java.util.List; import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.PBEParameterSpec; import javax.security.auth.x500.X500Principal; import org.bouncycastle.asn1.ASN1Encodable; @@ -188,12 +196,46 @@ public static void writeCertificateToPEM(X509Certificate certificate, File outpu } } - public static void writePrivateKeyToPem(PrivateKey privateKey, File output) throws Exception { + // Define PBE parameters + private static final String PBE_ALGORITHM = "PBEWithSHA1AndDESede"; + private static final int ITERATION_COUNT = 2048; + private static final int SALT_SIZE = 8; // 8 bytes of salt + + private static byte[] generateSalt() { + byte[] salt = new byte[SALT_SIZE]; + SecureRandom random = new SecureRandom(); + random.nextBytes(salt); + return salt; + } + + public static void writePrivateKeyToPem(PrivateKey privateKey, String password, File output) throws Exception { + + byte[] content = privateKey.getEncoded(); + ; + if (password != null) { + byte[] salt = generateSalt(); + PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray()); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(PBE_ALGORITHM); + SecretKey secretKey = keyFactory.generateSecret(pbeKeySpec); + Cipher cipher = Cipher.getInstance(PBE_ALGORITHM); + PBEParameterSpec pbeParamSpec = new PBEParameterSpec(salt, ITERATION_COUNT); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, pbeParamSpec); + + // Encode the private key in PKCS#8 format + PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKey.getEncoded()); + byte[] encryptedKeyBytes = cipher.doFinal(pkcs8EncodedKeySpec.getEncoded()); + + // Wrap encrypted data in EncryptedPrivateKeyInfo + EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(cipher.getParameters(), + encryptedKeyBytes); + content = encryptedPrivateKeyInfo.getEncoded(); + } + try (FileWriter fileWriter = new FileWriter(output); BufferedWriter pemWriter = new BufferedWriter(fileWriter)) { - pemWriter.write("-----BEGIN PRIVATE KEY-----\n"); - pemWriter.write(Base64.getEncoder().encodeToString(privateKey.getEncoded())); - pemWriter.write("\n-----END PRIVATE KEY-----\n\n"); + pemWriter.write("-----BEGIN " + (password != null ? "ENCRYPTED " : "") + "PRIVATE KEY-----\n"); + pemWriter.write(Base64.getEncoder().encodeToString(content)); + pemWriter.write("\n-----END " + (password != null ? "ENCRYPTED " : "") + "PRIVATE KEY-----\n\n"); } } diff --git a/certificate-generator/src/main/java/io/smallrye/certs/chain/CertificateChainGenerator.java b/certificate-generator/src/main/java/io/smallrye/certs/chain/CertificateChainGenerator.java index 7bfea1a..a14e6fe 100644 --- a/certificate-generator/src/main/java/io/smallrye/certs/chain/CertificateChainGenerator.java +++ b/certificate-generator/src/main/java/io/smallrye/certs/chain/CertificateChainGenerator.java @@ -85,13 +85,13 @@ public void generate() throws Exception { // 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.writePrivateKeyToPem(rootKeyPair.getPrivate(), null, new File(baseDir, "root.key")); CertificateUtils.writeCertificateToPEM(intermediaryCertificate, new File(baseDir, "intermediate.crt")); - CertificateUtils.writePrivateKeyToPem(intermediaryKeyPair.getPrivate(), new File(baseDir, "intermediate.key")); + CertificateUtils.writePrivateKeyToPem(intermediaryKeyPair.getPrivate(), null, new File(baseDir, "intermediate.key")); CertificateUtils.writeCertificateToPEM(leafCertificate, new File(baseDir, cn + ".crt"), intermediaryCertificate); - CertificateUtils.writePrivateKeyToPem(leafKeyPair.getPrivate(), new File(baseDir, cn + ".key")); + CertificateUtils.writePrivateKeyToPem(leafKeyPair.getPrivate(), null, new File(baseDir, cn + ".key")); } private KeyPair generateKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException { diff --git a/certificate-generator/src/test/java/io/smallrye/certs/GenerationTest.java b/certificate-generator/src/test/java/io/smallrye/certs/GenerationTest.java index cc9c5a7..9efb1a4 100644 --- a/certificate-generator/src/test/java/io/smallrye/certs/GenerationTest.java +++ b/certificate-generator/src/test/java/io/smallrye/certs/GenerationTest.java @@ -4,10 +4,14 @@ import java.io.File; import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; import java.util.Collection; +import io.smallrye.certs.pem.parsers.EncryptedPKCS8Parser; +import io.vertx.core.buffer.Buffer; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -69,6 +73,36 @@ void PEMGeneration(@Dir Path tempDir) throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void PEMGenerationWithEncryptedPrivateKey(@Dir Path tempDir) throws Exception { + CertificateRequest request = new CertificateRequest() + .withName("test") + .withFormat(Format.PEM) + .withPassword("secret"); + Collection files = new CertificateGenerator(tempDir, true).generate(request); + Assertions.assertThat(files).hasSize(1); + assertThat(files.stream().findFirst().get()).isInstanceOf(PemCertificateFiles.class); + + // We need to translate the PEM file. + Buffer buffer = decrypt(new File(tempDir.toFile(), "test.key"), "secret"); + + KeyCertOptions serverOptions = new PemKeyCertOptions() + .addKeyValue(buffer) + .addCertPath(new File(tempDir.toFile(), "test.crt").getAbsolutePath()); + TrustOptions clientOptions = new PemTrustOptions() + .addCertPath(new File(tempDir.toFile(), "test-ca.crt").getAbsolutePath()); + var server = VertxHttpHelper.createHttpServer(vertx, serverOptions); + var response = VertxHttpHelper.createHttpClientAndInvoke(vertx, server, clientOptions); + + assertThat(response.statusCode()).isEqualTo(200); + } + + private static Buffer decrypt(File pem, String password) throws IOException { + var content = Files.readString(pem.toPath()); + var parser = new EncryptedPKCS8Parser(); + return parser.decryptKey(content, password); + } + @Test void PCKS12Generation(@Dir Path tempDir) throws Exception { CertificateRequest request = new CertificateRequest() @@ -240,8 +274,11 @@ void mTLSWithJKSAndPemGeneration(@Dir Path tempDir) throws Exception { assertThat(clientKey).isFile(); File clientCert = new File(tempDir.toFile(), "test-client.crt"); assertThat(clientCert).isFile(); + + Buffer buffer = decrypt(new File(tempDir.toFile(), "test-client.key"), "secret"); + KeyCertOptions clientOptions = new PemKeyCertOptions() - .addKeyPath(clientKey.getAbsolutePath()) + .addKeyValue(buffer) .addCertPath(clientCert.getAbsolutePath()); File clientTrustStore = new File(tempDir.toFile(), "test-client-ca.crt"); assertThat(clientTrustStore).isFile(); @@ -277,8 +314,11 @@ void mTLSWithP12AndPemGeneration(@Dir Path tempDir) throws Exception { assertThat(clientKey).isFile(); File clientCert = new File(tempDir.toFile(), "test-client.crt"); assertThat(clientCert).isFile(); + + Buffer buffer = decrypt(new File(tempDir.toFile(), "test-client.key"), "secret"); + KeyCertOptions clientOptions = new PemKeyCertOptions() - .addKeyPath(clientKey.getAbsolutePath()) + .addKeyValue(buffer) .addCertPath(clientCert.getAbsolutePath()); File clientTrustStore = new File(tempDir.toFile(), "test-client-ca.crt"); assertThat(clientTrustStore).isFile(); diff --git a/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatMTLSTest.java b/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatMTLSTest.java index ea6ae0b..32c32f5 100644 --- a/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatMTLSTest.java +++ b/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatMTLSTest.java @@ -1,6 +1,8 @@ package io.smallrye.certs; +import io.smallrye.certs.pem.parsers.EncryptedPKCS8Parser; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.net.*; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.MethodSource; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -41,6 +45,12 @@ private static Stream testMixingKeystoreAndTruststoreFormat() { return list.stream(); } + private static Buffer decrypt(File pem, String password) throws IOException { + var content = Files.readString(pem.toPath()); + var parser = new EncryptedPKCS8Parser(); + return parser.decryptKey(content, password); + } + @ParameterizedTest @MethodSource public void testMixingKeystoreAndTruststoreFormat(Format serverKeystoreFormat, Format serverTruststoreFormat, @@ -48,9 +58,18 @@ public void testMixingKeystoreAndTruststoreFormat(Format serverKeystoreFormat, F generate(); KeyCertOptions serverKS = switch (serverKeystoreFormat) { - case PEM -> new PemKeyCertOptions() - .addKeyPath("target/certs/test-mixed-mtls.key") - .addCertPath("target/certs/test-mixed-mtls.crt"); + case PEM -> { + Buffer buffer = decrypt(new File("target/certs/test-mixed-mtls.key"), "password"); + if (buffer != null) { + yield new PemKeyCertOptions() + .addKeyValue(buffer) + .addCertPath("target/certs/test-mixed-mtls.crt"); + } else { + yield new PemKeyCertOptions() + .addKeyPath("target/certs/test-mixed-mtls.key") + .addCertPath("target/certs/test-mixed-mtls.crt"); + } + } case JKS -> new JksOptions() .setPath("target/certs/test-mixed-mtls-keystore.jks") .setPassword("password"); @@ -60,9 +79,18 @@ public void testMixingKeystoreAndTruststoreFormat(Format serverKeystoreFormat, F }; KeyCertOptions clientKS = switch (clientKeystoreFormat) { - case PEM -> new PemKeyCertOptions() - .addKeyPath("target/certs/test-mixed-mtls-client.key") - .addCertPath("target/certs/test-mixed-mtls-client.crt"); + case PEM -> { + Buffer buffer = decrypt(new File("target/certs/test-mixed-mtls-client.key"), "password"); + if (buffer != null) { + yield new PemKeyCertOptions() + .addKeyValue(buffer) + .addCertPath("target/certs/test-mixed-mtls-client.crt"); + } else { + yield new PemKeyCertOptions() + .addKeyPath("target/certs/test-mixed-mtls-client.key") + .addCertPath("target/certs/test-mixed-mtls-client.crt"); + } + } case JKS -> new JksOptions() .setPath("target/certs/test-mixed-mtls-client-keystore.jks") .setPassword("password"); diff --git a/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatTest.java b/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatTest.java index 587308f..c6de9d0 100644 --- a/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatTest.java +++ b/certificate-generator/src/test/java/io/smallrye/certs/MixedFormatTest.java @@ -1,6 +1,8 @@ package io.smallrye.certs; +import io.smallrye.certs.pem.parsers.EncryptedPKCS8Parser; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpClientResponse; import io.vertx.core.http.HttpServer; import io.vertx.core.net.*; @@ -11,6 +13,8 @@ import org.junit.jupiter.params.provider.MethodSource; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.List; import java.util.stream.Stream; @@ -44,6 +48,12 @@ private static Stream testMixingKeystoreAndTruststoreFormat() { Arguments.of(Format.PEM, Format.PKCS12)); } + private static Buffer decrypt(File pem, String password) throws IOException { + var content = Files.readString(pem.toPath()); + var parser = new EncryptedPKCS8Parser(); + return parser.decryptKey(content, password); + } + @ParameterizedTest @MethodSource public void testMixingKeystoreAndTruststoreFormat(Format keystoreFormat, Format truststoreFormat) throws Exception { @@ -51,10 +61,18 @@ public void testMixingKeystoreAndTruststoreFormat(Format keystoreFormat, Format HttpServer server = switch (keystoreFormat) { case PEM -> { - KeyCertOptions options = new PemKeyCertOptions() - .addKeyPath("target/certs/test-mixed.key") - .addCertPath("target/certs/test-mixed.crt"); - yield VertxHttpHelper.createHttpServer(vertx, options); + var buffer = decrypt(new File("target/certs/test-mixed.key"), "password"); + if (buffer != null) { + KeyCertOptions options = new PemKeyCertOptions() + .addKeyValue(buffer) + .addCertPath("target/certs/test-mixed.crt"); + yield VertxHttpHelper.createHttpServer(vertx, options); + } else { + KeyCertOptions options = new PemKeyCertOptions() + .addKeyPath("target/certs/test-mixed.key") + .addCertPath("target/certs/test-mixed.crt"); + yield VertxHttpHelper.createHttpServer(vertx, options); + } } case JKS -> { KeyCertOptions options = new JksOptions() diff --git a/pom.xml b/pom.xml index 6913520..3e822d2 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ certificate-generator certificate-generator-maven-plugin certificate-generator-junit5 + private-key-pem-parser diff --git a/private-key-pem-parser/pom.xml b/private-key-pem-parser/pom.xml new file mode 100644 index 0000000..a591e2e --- /dev/null +++ b/private-key-pem-parser/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + io.smallrye.certs + smallrye-certificate-generator-parent + 0.8.2-SNAPSHOT + + + private-key-pem-parser + + + + io.smallrye.common + smallrye-common-constraint + 2.8.0 + + + io.vertx + vertx-core + compile + + + + org.bouncycastle + bcprov-jdk18on + test + + + org.bouncycastle + bcpkix-jdk18on + test + + + org.assertj + assertj-core + test + + + org.junit-pioneer + junit-pioneer + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter + test + + + + + \ No newline at end of file diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifier.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifier.java new file mode 100644 index 0000000..2a0c257 --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifier.java @@ -0,0 +1,76 @@ +package io.smallrye.certs.pem.der; + +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; + +/** + * ANS.1 encoded object identifiers. + */ +public record ASN1ObjectIdentifier(byte[] value, String algorithmId) { + + /** + * DSA (ANSI X9.57 algorithm) + */ + static final ASN1ObjectIdentifier OID_1_2_840_10040_4_1 = ASN1ObjectIdentifier.from("2a8648ce380401", "DSA"); + /** + * PKCS #1 (RSA Encryption) + */ + public static final ASN1ObjectIdentifier OID_1_2_840_113549_1_1_1 = ASN1ObjectIdentifier.from("2A864886F70D010101", "RSA"); + + /** + * PKCS #1 (RSA PSS) + */ + static final ASN1ObjectIdentifier OID_1_2_840_113549_1_1_10 = ASN1ObjectIdentifier.from("2a864886f70d01010a", "RSA"); + /** + * ECDH 25519 key agreement algorithm (Curve X25519) - XDH + */ + static final ASN1ObjectIdentifier OID_1_3_101_110 = ASN1ObjectIdentifier.from("2b656e", "XDH"); + + /** + * ECDH 448 key agreement algorithm (Curve X448) - XDH + */ + static final ASN1ObjectIdentifier OID_1_3_101_111 = ASN1ObjectIdentifier.from("2b656f", "XDH"); + + /** + * EdDSA 25519 signature algorithm (Curve Ed25519) + */ + static final ASN1ObjectIdentifier OID_1_3_101_112 = ASN1ObjectIdentifier.from("2b6570", "EdDSA"); + /** + * EdDSA 448 signature algorithm (Curve Ed448) + */ + static final ASN1ObjectIdentifier OID_1_3_101_113 = ASN1ObjectIdentifier.from("2b6571", "EdDSA"); + + /** + * ANSI X9.62 public key type (ecPublicKey) + */ + static final ASN1ObjectIdentifier OID_1_2_840_10045_2_1 = ASN1ObjectIdentifier.from("2a8648ce3d0201", "EC"); + + static final List ALGORITHMS = List.of( + OID_1_2_840_113549_1_1_1, + OID_1_2_840_113549_1_1_10, + OID_1_2_840_10040_4_1, + OID_1_3_101_110, + OID_1_3_101_111, + OID_1_3_101_112, + OID_1_3_101_113, + OID_1_2_840_10045_2_1); + + /** + * SECG (Certicom) named elliptic curve (secp384r1) + */ + static final ASN1ObjectIdentifier OID_1_3_132_0_34 = ASN1ObjectIdentifier.from("2b81040022", "EC"); + + public static ASN1ObjectIdentifier from(String hexString, String algorithmId) { + return new ASN1ObjectIdentifier(HexFormat.of().parseHex(hexString), algorithmId); + } + + public static String getAlgorithmId(byte[] content) { + for (ASN1ObjectIdentifier oid : ALGORITHMS) { + if (Arrays.equals(oid.value(), content)) { + return oid.algorithmId(); + } + } + return null; + } +} \ No newline at end of file diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerEncoder.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerEncoder.java new file mode 100644 index 0000000..d22feaa --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerEncoder.java @@ -0,0 +1,105 @@ +package io.smallrye.certs.pem.der; + +import io.vertx.core.buffer.Buffer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * Simple ASN.1 DER encoder. + * Inspired by DerOutputStream.java. + */ +public class DerEncoder { + + private final Buffer payload = Buffer.buffer(); + + public void oid(ASN1ObjectIdentifier oid) { + int code = (oid != null) ? 0x06 : 0x05; // 5: NULL, 6: OID + encode(code, (oid != null) ? oid.value() : null); + } + + public void integer(int... encodedInteger) { + encode(0x02, toBytes(encodedInteger)); + } + + public void octetString(byte[] bytes) { + encode(0x04, bytes); + } + + public void sequence(byte[] bytes) { + // This is because in X.509 formats, the SEQUENCE type is used in constructed form. + // As the result, 6th bit is set to 1. By setting 1 in 6th bit for SEQUENCE universal tag (0x10) + // you get 0x30 + encode(0x30, bytes); + } + + public void addToSequence(byte[] bytes) { + payload.appendBytes(bytes); + } + + private void write(int c) { + payload.appendByte((byte) c); + } + + private void encode(int code, byte[] bytes) { + write(code); + int length = (bytes != null) ? bytes.length : 0; + if (length <= 127) { + write(length & 0xFF); + } else { + ByteArrayOutputStream lengthStream = new ByteArrayOutputStream(); + while (length != 0) { + lengthStream.write(length & 0xFF); + length = length >> 8; + } + byte[] lengthBytes = lengthStream.toByteArray(); + write(0x80 | lengthBytes.length); + for (int i = lengthBytes.length - 1; i >= 0; i--) { + write(lengthBytes[i]); + } + } + if (bytes != null) { + payload.appendBytes(bytes); + } + } + + private static byte[] toBytes(int... elements) { + if (elements == null) { + return null; + } + byte[] result = new byte[elements.length]; + for (int i = 0; i < elements.length; i++) { + result[i] = (byte) elements[i]; + } + return result; + } + + public byte[] toSequence() throws IOException { + DerEncoder sequenceEncoder = new DerEncoder(); + sequenceEncoder.sequence(toBytes()); + return sequenceEncoder.toBytes(); + } + + public byte[] toBytes() { + return payload.getBytes(); + } + + // /** + // * Creates a sequence or appends a DER object to a sequence. + // * + // * @param bytes the bytes representing the object to add. It's important that the bytes are a value DER encoded value (tag | length | value) + // * @throws IOException if the write fails + // */ + // public void sequence(byte[] bytes) throws IOException { + // // This is because in X.509 formats, the SEQUENCE type is used in constructed form. + // // As the result, 6th bit is set to 1. By setting 1 in 6th bit for SEQUENCE universal tag (0x10) + // // you get 0x30 + // if (payload.length() == 0) { + // encode(0x30, bytes); + // } else { + // payload.appendBytes(bytes); + // } + // } + +} \ No newline at end of file diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerParser.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerParser.java new file mode 100644 index 0000000..9cfa354 --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/der/DerParser.java @@ -0,0 +1,156 @@ +package io.smallrye.certs.pem.der; + +import io.vertx.core.buffer.Buffer; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An ASN.1 DER encoded element. + *

+ * The ASN.1 DER encoding is a standard way to encode data structures in binary form: + * {@code Identifier octets type | Length octets | Contents octets (Value) | End-of-Contents octets (only if indefinite form)} + * + * @see Wikipedia page + */ +public class DerParser { + + // See https://en.wikipedia.org/wiki/X.690#Encoding + public enum Type { + PRIMITIVE, + CONSTRUCTED; + + static Type from(byte b) { + return ((b & 0x20) == 0) ? PRIMITIVE : CONSTRUCTED; + } + + } + + // See https://en.wikipedia.org/wiki/X.690#Identifier_octets. + public enum Tag { + // Usual Tag, attention to the ASN Sequence using 0x30 and not 0x10 because it's a constructed type. + INTEGER(0x02), + OCTET_STRING(0x04), + OBJECT_IDENTIFIER(0x06), + SEQUENCE(0x10), + ASN1_SEQUENCE(0x30); + + private final int number; + + Tag(int n) { + number = n; + } + + public int number() { + return number; + } + } + + /** + * The type of the element (primitive or constructed). + */ + private final Type type; + + /** + * The tag of the element (decimal value). + */ + private final long tag; + + /** + * The length of the content. + * Be aware this is not the size of the byte array. + */ + private final int length; + + /** + * The content of the element. + */ + private final Buffer content; + + /** + * The size of the element (tag + length + content). + */ + private final int size; + + /** + * The position in the content. + * Used to iterate over the content (sequence). + */ + private int cursor; + + public DerParser(byte[] bytes) { + var position = new AtomicInteger(0); + var buffer = Buffer.buffer(bytes); + byte b = buffer.getByte(position.getAndIncrement()); + type = Type.from(b); + tag = decodeTag(b, buffer, position); + length = decodeLength(buffer, position); + content = buffer.slice(position.get(), position.get() + length); + size = position.get() + length; + cursor = 0; // Position in the content + } + + private long decodeTag(byte b, Buffer bytes, AtomicInteger position) { + // See https://en.wikipedia.org/wiki/X.690#Identifier_octets + long t = (b & 0x1F); + if (t != 0x1F) { + return t; + } + t = 0; + b = bytes.getByte(position.getAndIncrement()); + while ((b & 0x80) != 0) { + t <<= 7; + t = t | (b & 0x7F); + b = bytes.getByte(position.getAndIncrement()); + } + return t; + } + + private int decodeLength(Buffer bytes, AtomicInteger position) { + byte b = bytes.getByte(position.getAndIncrement()); + if ((b & 0x80) == 0) { + return b & 0x7F; + } + int numberOfLengthBytes = (b & 0x7F); + if (numberOfLengthBytes == 0) { + throw new IllegalArgumentException("Indefinite form is not supported"); + } + int length = 0; + for (int i = 0; i < numberOfLengthBytes; i++) { + length <<= 8; + length |= (bytes.getByte(position.getAndIncrement()) & 0xFF); + } + return length; + } + + public Buffer content() { + return this.content; + } + + public byte[] toByteArray() { + return this.content.getBytes(); + } + + public Type type() { + return type; + } + + public long tag() { + return tag; + } + + public int length() { + return length; + } + + public DerParser next() { + if (tag == Tag.ASN1_SEQUENCE.number() || tag == Tag.SEQUENCE.number()) { + if (cursor < length) { + var nested = new DerParser(content.getBytes(cursor, length)); + cursor = cursor + nested.size; + return nested; + } + } + return null; + } + +} \ No newline at end of file diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8Parser.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8Parser.java new file mode 100644 index 0000000..dc6d7fd --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8Parser.java @@ -0,0 +1,102 @@ +package io.smallrye.certs.pem.parsers; + +import io.vertx.core.buffer.Buffer; + +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.io.IOException; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +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.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class EncryptedPKCS8Parser implements PKPemParser { + + private static final String PKCS8_ENCRYPTED_START = "-+BEGIN\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS8_ENCRYPTED_END = "-+END\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final Pattern PATTERN = Pattern.compile(PKCS8_ENCRYPTED_START + BASE64_TEXT + PKCS8_ENCRYPTED_END, + Pattern.CASE_INSENSITIVE); + + private static final List ALGORITHMS = List.of("RSA", "RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH"); + + public EncryptedPKCS8Parser() { + } + + @Override + public PrivateKey getKey(String content, String password) { + try { + Matcher matcher = PATTERN.matcher(content); + if (matcher.find()) { + var encoded = matcher.group(BASE64_TEXT_GROUP); + var decoded = decodeBase64(encoded); + return extract(decoded, password); + } + } catch (Exception e) { + return null; + } + // Does not match PKCS8 encrypted pattern + return null; + } + + private PrivateKey extract(byte[] decoded, String password) { + var key = decrypt(decoded, password); + for (String algo : ALGORITHMS) { + try { + KeyFactory factory = KeyFactory.getInstance(algo); + return factory.generatePrivate(key); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + // Ignore + } + } + return null; + } + + public static final String PBES2_ALGORITHM = "PBES2"; + + static PKCS8EncodedKeySpec decrypt(byte[] bytes, String password) { + try { + EncryptedPrivateKeyInfo keyInfo = new EncryptedPrivateKeyInfo(bytes); + AlgorithmParameters algorithmParameters = keyInfo.getAlgParameters(); + String encryptionAlgorithm = getEncryptionAlgorithm(algorithmParameters, keyInfo.getAlgName()); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptionAlgorithm); + SecretKey key = keyFactory.generateSecret(new PBEKeySpec(password.toCharArray())); + Cipher cipher = Cipher.getInstance(encryptionAlgorithm); + cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters); + return keyInfo.getKeySpec(cipher); + } catch (IOException | GeneralSecurityException ex) { + throw new IllegalArgumentException("Error decrypting private key", ex); + } + } + + private static String getEncryptionAlgorithm(AlgorithmParameters algParameters, String algName) { + if (algParameters != null && PBES2_ALGORITHM.equals(algName)) { + return algParameters.toString(); + } + return algName; + } + + public Buffer decryptKey(String content, String secret) { + var pk = getKey(content, secret); + if (pk == null) { + return null; + } + Buffer buffer = Buffer.buffer(); + buffer.appendString("-----BEGIN PRIVATE KEY-----\n"); + buffer.appendString(Base64.getEncoder().encodeToString(pk.getEncoded())); + buffer.appendString("\n-----END PRIVATE KEY-----\n\n"); + + return buffer; + } +} diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS1Parser.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS1Parser.java new file mode 100644 index 0000000..cf05ec6 --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS1Parser.java @@ -0,0 +1,57 @@ +package io.smallrye.certs.pem.parsers; + +import io.smallrye.certs.pem.der.DerEncoder; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parse PKCS1 private key (RSA) from PEM format. + */ +public class PKCS1Parser implements PKPemParser { + + private static final String PKCS1_RSA_START = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS1_RSA_END = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final Pattern PKCS1_PATTERN = Pattern.compile(PKCS1_RSA_START + BASE64_TEXT + PKCS1_RSA_END, + Pattern.CASE_INSENSITIVE); + + @Override + public PrivateKey getKey(String content, String ignored) { + try { + Matcher matcher = PKCS1_PATTERN.matcher(content); + if (matcher.find()) { + var encoded = matcher.group(BASE64_TEXT_GROUP); + var decoded = decodeBase64(encoded); + return extract(decoded); + } + } catch (Exception e) { + return null; + } + // Does not match PKCS1 pattern + return null; + } + + private PrivateKey extract(byte[] decoded) { + try { + DerEncoder encoder = new DerEncoder(); + encoder.integer(0x00); // Version 0 + + DerEncoder algorithmIdentifier = new DerEncoder(); + algorithmIdentifier.oid(RSA_ALGORITHM); + algorithmIdentifier.oid(null); + + encoder.sequence(algorithmIdentifier.toBytes()); + encoder.octetString(decoded); + var spec = new PKCS8EncodedKeySpec(encoder.toSequence()); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS8Parser.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS8Parser.java new file mode 100644 index 0000000..0c2ebc9 --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKCS8Parser.java @@ -0,0 +1,76 @@ +package io.smallrye.certs.pem.parsers; + +import io.smallrye.certs.pem.der.ASN1ObjectIdentifier; +import io.smallrye.certs.pem.der.DerParser; + +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.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PKCS8Parser implements PKPemParser { + + private static final String PKCS8_START = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS8_END = "-+END\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final Pattern PATTERN = Pattern.compile(PKCS8_START + BASE64_TEXT + PKCS8_END, Pattern.CASE_INSENSITIVE); + + private static final List ALGORITHMS = List.of("RSA", "RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH"); + + public PKCS8Parser() { + } + + @Override + public PrivateKey getKey(String content, String password) { + try { + Matcher matcher = PATTERN.matcher(content); + if (matcher.find()) { + var encoded = matcher.group(BASE64_TEXT_GROUP); + var decoded = decodeBase64(encoded); + return extract(decoded, password); + } + } catch (Exception e) { + return null; + } + // Does not match PKCS8 encrypted pattern + return null; + } + + private PrivateKey extract(byte[] decoded, String ignored) { + DerParser parser = new DerParser(decoded); + if (parser.type() != DerParser.Type.CONSTRUCTED || parser.tag() != DerParser.Tag.SEQUENCE.number()) { + throw new IllegalArgumentException("Key spec should be an encoded sequence"); + } + var version = parser.next(); + if (version.type() != DerParser.Type.PRIMITIVE || version.tag() != DerParser.Tag.INTEGER.number()) { + throw new IllegalArgumentException("Key spec should contain the (integer) version"); + } + + var seq = parser.next(); + if (seq.type() != DerParser.Type.CONSTRUCTED || seq.tag() != DerParser.Tag.SEQUENCE.number()) { + throw new IllegalArgumentException("Key spec should contain a sequence"); + } + + var algorithmId = seq.next(); + if (algorithmId.type() != DerParser.Type.PRIMITIVE || algorithmId.tag() != DerParser.Tag.OBJECT_IDENTIFIER.number()) { + throw new IllegalArgumentException("Key spec container expects an object identifier as algorithm id"); + } + String algorithm = ASN1ObjectIdentifier.getAlgorithmId(algorithmId.content().getBytes()); + var spec = (algorithm != null) ? new PKCS8EncodedKeySpec(decoded, algorithm) : new PKCS8EncodedKeySpec(decoded); + + for (String algo : ALGORITHMS) { + try { + KeyFactory factory = KeyFactory.getInstance(algo); + return factory.generatePrivate(spec); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + // Ignore + } + } + return null; + } +} diff --git a/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKPemParser.java b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKPemParser.java new file mode 100644 index 0000000..c0ba938 --- /dev/null +++ b/private-key-pem-parser/src/main/java/io/smallrye/certs/pem/parsers/PKPemParser.java @@ -0,0 +1,21 @@ +package io.smallrye.certs.pem.parsers; + +import io.smallrye.certs.pem.der.ASN1ObjectIdentifier; + +import java.security.PrivateKey; +import java.util.Base64; + +public interface PKPemParser { + + String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + int BASE64_TEXT_GROUP = 1; + + ASN1ObjectIdentifier RSA_ALGORITHM = ASN1ObjectIdentifier.OID_1_2_840_113549_1_1_1; + + PrivateKey getKey(String content, String password); + + default byte[] decodeBase64(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64.getDecoder().decode(contentBytes); + } +} diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifierTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifierTest.java new file mode 100644 index 0000000..ccdfae1 --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/ASN1ObjectIdentifierTest.java @@ -0,0 +1,90 @@ +package io.smallrye.certs.pem.der; + +import org.junit.jupiter.api.Test; +import java.util.HexFormat; +import static org.junit.jupiter.api.Assertions.*; + +class ASN1ObjectIdentifierTest { + + @Test + void testOID_1_2_840_10040_4_1() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_2_840_10040_4_1; + byte[] expected = HexFormat.of().parseHex("2a8648ce380401"); + assertArrayEquals(expected, oid.value(), "OID 1.2.840.10040.4.1 encoding mismatch"); + } + + @Test + void testOID_1_2_840_113549_1_1_1() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_2_840_113549_1_1_1; + byte[] expected = HexFormat.of().parseHex("2A864886F70D010101"); + assertArrayEquals(expected, oid.value(), "OID 1.2.840.113549.1.1.1 encoding mismatch"); + } + + @Test + void testOID_1_2_840_113549_1_1_10() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_2_840_113549_1_1_10; + byte[] expected = HexFormat.of().parseHex("2a864886f70d01010a"); + assertArrayEquals(expected, oid.value(), "OID 1.2.840.113549.1.1.10 encoding mismatch"); + } + + @Test + void testOID_1_3_101_110() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_3_101_110; + byte[] expected = HexFormat.of().parseHex("2b656e"); + assertArrayEquals(expected, oid.value(), "OID 1.3.101.110 encoding mismatch"); + } + + @Test + void testOID_1_3_101_111() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_3_101_111; + byte[] expected = HexFormat.of().parseHex("2b656f"); + assertArrayEquals(expected, oid.value(), "OID 1.3.101.111 encoding mismatch"); + } + + @Test + void testOID_1_3_101_112() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_3_101_112; + byte[] expected = HexFormat.of().parseHex("2b6570"); + assertArrayEquals(expected, oid.value(), "OID 1.3.101.112 encoding mismatch"); + } + + @Test + void testOID_1_3_101_113() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_3_101_113; + byte[] expected = HexFormat.of().parseHex("2b6571"); + assertArrayEquals(expected, oid.value(), "OID 1.3.101.113 encoding mismatch"); + } + + @Test + void testOID_1_2_840_10045_2_1() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_2_840_10045_2_1; + byte[] expected = HexFormat.of().parseHex("2a8648ce3d0201"); + assertArrayEquals(expected, oid.value(), "OID 1.2.840.10045.2.1 encoding mismatch"); + } + + @Test + void testOID_1_3_132_0_34() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.OID_1_3_132_0_34; + byte[] expected = HexFormat.of().parseHex("2b81040022"); + assertArrayEquals(expected, oid.value(), "OID 1.3.132.0.34 encoding mismatch"); + } + + @Test + void testCustomOIDCreation() { + ASN1ObjectIdentifier oid = ASN1ObjectIdentifier.from("2a8648", "foo"); + byte[] expected = HexFormat.of().parseHex("2a8648"); + assertArrayEquals(expected, oid.value(), "Custom OID encoding mismatch"); + assertEquals("foo", oid.algorithmId(), "Custom OID algorithm ID mismatch"); + } + + @Test + void testGetAlgorithmId() { + byte[] content = HexFormat.of().parseHex("2A864886F70D010101"); + String algorithmId = ASN1ObjectIdentifier.getAlgorithmId(content); + assertEquals("RSA", algorithmId, "Algorithm ID mismatch"); + + content = HexFormat.of().parseHex("2b6571"); + algorithmId = ASN1ObjectIdentifier.getAlgorithmId(content); + assertEquals("EdDSA", algorithmId, "Algorithm ID mismatch"); + } +} \ No newline at end of file diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerEncoderTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerEncoderTest.java new file mode 100644 index 0000000..eb67f55 --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerEncoderTest.java @@ -0,0 +1,94 @@ +package io.smallrye.certs.pem.der; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HexFormat; + +import static org.junit.jupiter.api.Assertions.*; + +class DerEncoderTest { + + @Test + void testIntegerEncoding() { + DerEncoder encoder = new DerEncoder(); + encoder.integer(0x05); // INTEGER with value 5 + + byte[] expected = new byte[] { 0x02, 0x01, 0x05 }; // INTEGER tag (0x02), length 1, value 5 + assertArrayEquals(expected, encoder.toBytes()); + } + + @Test + void testOctetStringEncoding() { + DerEncoder encoder = new DerEncoder(); + encoder.octetString(new byte[] { 0x41, 0x42 }); // OCTET STRING with value "AB" + + byte[] expected = new byte[] { 0x04, 0x02, 0x41, 0x42 }; // OCTET STRING tag (0x04), length 2, value "AB" + assertArrayEquals(expected, encoder.toBytes()); + } + + @Test + void testObjectIdEncoding() { + DerEncoder encoder = new DerEncoder(); + encoder.oid(new ASN1ObjectIdentifier(new byte[] { 0x2A, (byte) 0x86, 0x48 }, "foo")); // OID representing 1.2.840 + byte[] expected = new byte[] { 0x06, 0x03, 0x2A, (byte) 0x86, 0x48 }; // OID tag (0x06), length 3, value 1.2.840 + assertArrayEquals(expected, encoder.toBytes()); + } + + @Test + void testSequenceEncoding() { + DerEncoder encoder = new DerEncoder(); + encoder.sequence(new byte[] { 0x02, 0x01, 0x0A, 0x04, 0x02, 0x41, 0x42 }); // SEQUENCE with INTEGER (10) and OCTET STRING ("AB") + + byte[] expected = new byte[] { + 0x30, 0x07, // SEQUENCE tag (0x30), length 7 + 0x02, 0x01, 0x0A, // INTEGER (10) + 0x04, 0x02, 0x41, 0x42 // OCTET STRING ("AB") + }; + assertArrayEquals(expected, encoder.toBytes()); + } + + @Test + void testMultiByteLengthEncoding() { + DerEncoder encoder = new DerEncoder(); + byte[] longContent = new byte[256]; // Content of 256 bytes for multi-byte length test + encoder.octetString(longContent); + + byte[] result = encoder.toBytes(); + + assertEquals(0x04, result[0]); // OCTET STRING tag + assertEquals((byte) 0x82, result[1]); // Multi-byte length indicator (0x82 means next two bytes indicate length) + assertEquals(0x01, result[2]); // Length high byte (256 in two bytes) + assertEquals(0x00, result[3]); // Length low byte + assertEquals(256, result.length - 4); // Content length + } + + @Test + void testSequenceContainingMultipleElements() { + DerEncoder sequenceEncoder = new DerEncoder(); + DerEncoder intEncoder = new DerEncoder(); + DerEncoder stringEncoder = new DerEncoder(); + + intEncoder.integer(0x05); // INTEGER with value 5 + stringEncoder.octetString(new byte[] { 0x41, 0x42 }); // OCTET STRING with value "AB" + + sequenceEncoder.sequence(intEncoder.toBytes()); + sequenceEncoder.addToSequence(stringEncoder.toBytes()); + + byte[] sequence = sequenceEncoder.toBytes(); + + assertEquals(0x30, sequence[0]); // ASN1 SEQUENCE tag + assertEquals(2 + intEncoder.toBytes().length + stringEncoder.toBytes().length, sequence.length); + } + + @Test + void testEmptyOidEncoding() { + DerEncoder encoder = new DerEncoder(); + encoder.oid(null); // NULL OID + + byte[] expected = new byte[] { 0x05, 0x00 }; // NULL tag (0x05), length 0 + assertArrayEquals(expected, encoder.toBytes()); + } + +} diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerParserTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerParserTest.java new file mode 100644 index 0000000..2407821 --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/der/DerParserTest.java @@ -0,0 +1,221 @@ +package io.smallrye.certs.pem.der; + +import io.vertx.core.buffer.Buffer; +import org.junit.jupiter.api.Test; + +import java.util.HexFormat; + +import static org.junit.jupiter.api.Assertions.*; + +class DerParserTest { + + @Test + void testIntegerParsing() { + // ASN.1 DER encoding of an INTEGER with value 5 + byte[] bytes = new byte[] { 0x02, 0x01, 0x05 }; // TAG: 0x02 (INTEGER), LENGTH: 0x01, VALUE: 0x05 + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.INTEGER.number(), parser.tag()); + assertEquals(5, parser.content().getByte(0)); + } + + @Test + void testOctetStringParsing() { + // ASN.1 DER encoding of an OCTET STRING with value "AB" + byte[] bytes = new byte[] { 0x04, 0x02, 0x41, 0x42 }; // TAG: 0x04 (OCTET STRING), LENGTH: 0x02, VALUE: "AB" + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.OCTET_STRING.number(), parser.tag()); + assertEquals(2, parser.content().length()); + assertEquals((byte) 0x41, parser.content().getByte(0)); + assertEquals((byte) 0x42, parser.content().getByte(1)); + } + + @Test + void testObjectIdentifierParsing() { + // ASN.1 DER encoding of an OBJECT IDENTIFIER with value 1.2.840.113549 + byte[] bytes = new byte[] { 0x06, 0x06, 0x2A, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xF7, 0x0D }; + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.OBJECT_IDENTIFIER.number(), parser.tag()); + assertEquals(6, parser.content().length()); + } + + @Test + void testSequenceParsing() { + // ASN.1 DER encoding of a SEQUENCE containing an INTEGER (5) and BOOLEAN (true) + byte[] bytes = new byte[] { + 0x10, 0x06, // SEQUENCE TAG (0x10) with LENGTH 6 + 0x02, 0x01, 0x05, // INTEGER TAG (0x02) with LENGTH 1 and VALUE 5 + 0x01, 0x01, (byte) 0xFF // BOOLEAN TAG (0x01) with LENGTH 1 and VALUE true (0xFF) + }; + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.SEQUENCE.number(), parser.tag()); + assertEquals(6, parser.content().length()); + } + + @Test + void testMultiByteLengthParsing() { + // ASN.1 DER encoding of an OCTET STRING with a long length (256 bytes) + byte[] bytes = new byte[260]; + bytes[0] = 0x04; // TAG for OCTET STRING + bytes[1] = (byte) 0x82; // Length in two bytes (0x82 means multi-byte length follows) + bytes[2] = 0x01; // High byte of length (256 bytes) + bytes[3] = 0x00; // Low byte of length (256 bytes) + + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.OCTET_STRING.number(), parser.tag()); + assertEquals(256, parser.content().length()); + } + + @Test + void testAsn1SequenceParsing() { + // ASN.1 DER encoding of a SEQUENCE (0x30) containing: + // - INTEGER (value: 10) + // - OCTET STRING (value: "AB") + byte[] bytes = new byte[] { + 0x30, 0x07, // SEQUENCE TAG (0x30) with LENGTH 7 + 0x02, 0x01, 0x0A, // INTEGER TAG (0x02) with LENGTH 1 and VALUE 10 + 0x04, 0x02, 0x41, 0x42 // OCTET STRING TAG (0x04) with LENGTH 2 and VALUE "AB" + }; + + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.CONSTRUCTED, parser.type()); + assertEquals(0x10, parser.tag()); // SEQUENCE tag is 0x10 in the enum mapping + assertEquals(7, parser.content().length()); + + // Now verify the inner elements + Buffer content = parser.content(); + + // INTEGER element + DerParser intParser = new DerParser(new byte[] { content.getByte(0), content.getByte(1), content.getByte(2) }); + assertEquals(DerParser.Type.PRIMITIVE, intParser.type()); + assertEquals(DerParser.Tag.INTEGER.number(), intParser.tag()); + assertEquals(1, intParser.content().length()); + assertEquals(10, intParser.content().getByte(0)); + + // OCTET STRING element + DerParser octetStringParser = new DerParser( + new byte[] { content.getByte(3), content.getByte(4), content.getByte(5), content.getByte(6) }); + assertEquals(DerParser.Type.PRIMITIVE, octetStringParser.type()); + assertEquals(DerParser.Tag.OCTET_STRING.number(), octetStringParser.tag()); + assertEquals(2, octetStringParser.length()); + assertEquals(2, octetStringParser.content().length()); + assertEquals((byte) 0x41, octetStringParser.content().getByte(0)); + assertEquals((byte) 0x42, octetStringParser.content().getByte(1)); + } + + @Test + void testAsn1SequenceParsingUsingNext() { + // ASN.1 DER encoding of a SEQUENCE (0x30) containing: + // - INTEGER (value: 10) + // - OCTET STRING (value: "AB") + byte[] bytes = new byte[] { + 0x30, 0x07, // SEQUENCE TAG (0x30) with LENGTH 7 + 0x02, 0x01, 0x0A, // INTEGER TAG (0x02) with LENGTH 1 and VALUE 10 + 0x04, 0x02, 0x41, 0x42 // OCTET STRING TAG (0x04) with LENGTH 2 and VALUE "AB" + }; + + DerParser parser = new DerParser(bytes); + + assertEquals(DerParser.Type.CONSTRUCTED, parser.type()); + assertEquals(0x10, parser.tag()); // SEQUENCE tag is 0x10 in the enum mapping + assertEquals(7, parser.length()); + + DerParser first = parser.next(); + DerParser second = parser.next(); + DerParser third = parser.next(); + + // INTEGER element + assertEquals(DerParser.Type.PRIMITIVE, first.type()); + assertEquals(DerParser.Tag.INTEGER.number(), first.tag()); + assertEquals(1, first.content().length()); + assertEquals(10, first.content().getByte(0)); + + // OCTET STRING element + assertEquals(DerParser.Type.PRIMITIVE, second.type()); + assertEquals(DerParser.Tag.OCTET_STRING.number(), second.tag()); + assertEquals(2, second.length()); + assertEquals(2, second.content().length()); + assertEquals((byte) 0x41, second.content().getByte(0)); + assertEquals((byte) 0x42, second.content().getByte(1)); + + // No more elements + assertNull(third); + assertNull(parser.next()); + // Illegal calls + assertNull(first.next()); + assertNull(second.next()); + } + + @Test + void testIndefiniteFormError() { + // Example of an invalid DER encoding (indefinite form is not supported in DER) + byte[] bytes = new byte[] { 0x04, (byte) 0x80 }; // OCTET STRING with indefinite length form (0x80) + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + new DerParser(bytes); + }); + assertTrue(exception.getMessage().contains("Indefinite form is not supported")); + } + + @Test + void testParseNestedDERSequence() { + // Pre-constructed DER byte array representing the structure: + // SEQUENCE (outer) -> SEQUENCE (inner) -> INTEGER (42) + byte[] derData = { + 0x10, 0x05, // Outer SEQUENCE, length 5 bytes + 0x10, 0x03, // Inner SEQUENCE, length 4 bytes + 0x02, 0x01, 0x2A // INTEGER with value 42 (0x2A in hex) + }; + + // Initialize DER parser + DerParser parser = new DerParser(derData); + + // Check that the first object is a sequence + assertEquals(DerParser.Type.PRIMITIVE, parser.type()); + assertEquals(DerParser.Tag.SEQUENCE.number(), parser.tag()); + + var innerSeq = parser.next(); + assertEquals(DerParser.Type.PRIMITIVE, innerSeq.type()); + assertEquals(DerParser.Tag.SEQUENCE.number(), innerSeq.tag()); + var num = innerSeq.next(); + assertEquals(DerParser.Type.PRIMITIVE, num.type()); + assertEquals(DerParser.Tag.INTEGER.number(), num.tag()); + assertEquals(42, num.content().getByte(0)); + } + + @Test + void testParseNestedDERASN1Sequence() { + // Pre-constructed DER byte array representing the structure: + // SEQUENCE (outer) -> SEQUENCE (inner) -> INTEGER (42) + byte[] derData = { + 0x30, 0x05, // Outer SEQUENCE, length 5 bytes + 0x30, 0x03, // Inner SEQUENCE, length 4 bytes + 0x02, 0x01, 0x2A // INTEGER with value 42 (0x2A in hex) + }; + + // Initialize DER parser + DerParser parser = new DerParser(derData); + + // Check that the first object is a sequence + assertEquals(DerParser.Type.CONSTRUCTED, parser.type()); + assertEquals(DerParser.Tag.SEQUENCE.number(), parser.tag()); + + var innerSeq = parser.next(); + assertEquals(DerParser.Type.CONSTRUCTED, innerSeq.type()); + assertEquals(DerParser.Tag.SEQUENCE.number(), innerSeq.tag()); + var num = innerSeq.next(); + assertEquals(DerParser.Type.PRIMITIVE, num.type()); + assertEquals(DerParser.Tag.INTEGER.number(), num.tag()); + assertEquals(42, num.content().getByte(0)); + } +} diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8ParserTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8ParserTest.java new file mode 100644 index 0000000..36e0265 --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/EncryptedPKCS8ParserTest.java @@ -0,0 +1,129 @@ +package io.smallrye.certs.pem.parsers; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PKCS8Generator; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8EncryptorBuilder; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.OutputEncryptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class EncryptedPKCS8ParserTest { + + private PrivateKey originalPrivateKey; + private EncryptedPKCS8Parser parser; + private String encryptedPKCS8Key; + private static final String password = "correctPassword"; + + @BeforeEach + void setup() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + originalPrivateKey = keyGen.generateKeyPair().getPrivate(); + + try (StringWriter writer = new StringWriter(); + JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) { + + // Define the encryptor with desired algorithm and passphrase + OutputEncryptor encryptor = new JceOpenSSLPKCS8EncryptorBuilder( + PKCS8Generator.PBE_SHA1_3DES).setPassword(password.toCharArray()).build(); + + // Create an encrypted PKCS8 format + PKCS8Generator pkcs8Generator = new JcaPKCS8Generator(originalPrivateKey, encryptor); + + // Write the encrypted private key to a file in PEM format + pemWriter.writeObject(pkcs8Generator); + pemWriter.close(); + encryptedPKCS8Key = writer.toString(); + } catch (OperatorCreationException e) { + throw new RuntimeException(e); + } + parser = new EncryptedPKCS8Parser(); + } + + @Test + void testParseValidEncryptedKeyWithCorrectPassword() { + PrivateKey privateKey = parser.getKey(encryptedPKCS8Key, password); + assertNotNull(privateKey, "Private key should not be null with correct password"); + } + + @Test + void testParseValidEncryptedKeyWithIncorrectPassword() { + String incorrectPassword = "wrongPassword"; + PrivateKey privateKey = parser.getKey(encryptedPKCS8Key, incorrectPassword); + assertNull(privateKey, "Private key should be null with incorrect password"); + } + + @Test + void testParseInvalidEncryptedKeyFormat() { + String invalidKey = """ + -----BEGIN ENCRYPTED PRIVATE KEY----- + InvalidBase64Data== + -----END ENCRYPTED PRIVATE KEY----- + """; + PrivateKey privateKey = parser.getKey(invalidKey, password); + assertNull(privateKey, "Private key should be null for invalid key format"); + } + + @Test + void testParseNonEncryptedRandomKey() { + String nonEncryptedKey = """ + -----BEGIN PRIVATE KEY----- + MIIBVgIBADANBgkqhkiG9w0BAQEFAASCATwwggE4AgEAAkEA...moreBase64Data...== + -----END PRIVATE KEY----- + """; + PrivateKey privateKey = parser.getKey(nonEncryptedKey, password); + assertNull(privateKey, "Private key should be null for non-encrypted key"); + } + + @Test + void testParseValidEncryptedPkcs8Key() { + PrivateKey decryptedKey = parser.getKey(encryptedPKCS8Key, password); + assertNotNull(decryptedKey, "Decrypted private key should not be null for a valid encrypted PKCS8 key"); + } + + @Test + void testParseWithIncorrectPassword() { + PrivateKey decryptedKey = parser.getKey(encryptedPKCS8Key, "wrongPassword"); + assertNull(decryptedKey, "Decrypted private key should be null when an incorrect password is used"); + } + + @Test + void testDecryptedKeyAttributesMatchOriginal() throws Exception { + PrivateKey decryptedKey = parser.getKey(encryptedPKCS8Key, password); + assertNotNull(decryptedKey, "Decrypted private key should not be null for a valid encrypted PKCS8 key"); + + // Verify that the decrypted key and original key are of the same type and have the same attributes + assertEquals(originalPrivateKey.getAlgorithm(), decryptedKey.getAlgorithm(), + "Algorithm should match original private key"); + assertArrayEquals(originalPrivateKey.getEncoded(), decryptedKey.getEncoded(), + "Decrypted key should match the original private key in encoding"); + } + + @Test + void testParseNonEncryptedKey() { + String nonEncryptedPem = Base64.getEncoder().encodeToString(originalPrivateKey.getEncoded()); + String nonEncryptedKeyPem = """ + -----BEGIN PRIVATE KEY----- + %s + -----END PRIVATE KEY----- + """.formatted(nonEncryptedPem); + + PrivateKey decryptedKey = parser.getKey(nonEncryptedKeyPem, password); + assertNull(decryptedKey, "Parsed private key should be null for a non-encrypted PKCS8 PEM format"); + } +} \ No newline at end of file diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS1ParserTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS1ParserTest.java new file mode 100644 index 0000000..1a85c93 --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS1ParserTest.java @@ -0,0 +1,212 @@ +package io.smallrye.certs.pem.parsers; + +import org.bouncycastle.asn1.pkcs.RSAPrivateKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringWriter; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.Security; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPrivateCrtKeySpec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PKCS1ParserTest { + + private PKCS1Parser parser; + private String key; + private PrivateKey originalPrivateKey; + + @BeforeEach + void setup() throws NoSuchAlgorithmException, NoSuchProviderException, IOException, InvalidKeySpecException { + Security.addProvider(new BouncyCastleProvider()); + + // Step 1: Generate RSA Key Pair + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA", "BC"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + originalPrivateKey = keyPair.getPrivate(); + + // Step 2: Extract the key parameters from the private key + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKeySpec privateKeySpec = keyFactory.getKeySpec(originalPrivateKey, RSAPrivateCrtKeySpec.class); + + // Step 3: Construct PKCS#1 format using RSAPrivateKey structure + RSAPrivateKey pkcs1PrivateKey = new RSAPrivateKey( + privateKeySpec.getModulus(), + privateKeySpec.getPublicExponent(), + privateKeySpec.getPrivateExponent(), + privateKeySpec.getPrimeP(), + privateKeySpec.getPrimeQ(), + privateKeySpec.getPrimeExponentP(), + privateKeySpec.getPrimeExponentQ(), + privateKeySpec.getCrtCoefficient()); + + // Step 4: Convert the RSAPrivateKey ASN.1 object to DER-encoded bytes + byte[] pkcs1Bytes = pkcs1PrivateKey.getEncoded(); + + // Step 5: Encode as PEM using PemWriter + StringWriter stringWriter = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(stringWriter)) { + pemWriter.writeObject(new PemObject("RSA PRIVATE KEY", pkcs1Bytes)); + } + + key = stringWriter.toString(); + parser = new PKCS1Parser(); + } + + @Test + void testParseValidPkcs1Key() { + PrivateKey pk = parser.getKey(key, null); + assertNotNull(pk, "Parsed private key should not be null for valid PKCS1 key"); + + // Verify the decoded key length or check against a known portion of the DER-encoded output + byte[] keyBytes = pk.getEncoded(); + assertTrue(keyBytes.length > 0, "Encoded key bytes should not be empty"); + + // Check the initial bytes for ASN.1 sequence (SEQUENCE tag, length, etc.) for PKCS8 format + assertEquals(0x30, keyBytes[0] & 0xFF, + "Key should start with SEQUENCE tag (0x30 in DER)"); + } + + @Test + void testParseInvalidPkcs1KeyFormat() { + String invalidKey = """ + -----BEGIN RSA PRIVATE KEY----- + InvalidBase64Data== + -----END RSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(invalidKey, null); + assertNull(pk, "Parsed private key should be null for invalid PKCS1 key format"); + } + + @Test + void testParseNonMatchingKey() { + String nonMatchingKey = """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsdklj+ksdLq93Q== + -----END PUBLIC KEY----- + """; + PrivateKey pk = parser.getKey(nonMatchingKey, null); + assertNull(pk, "Parsed PKCS8EncodedKeySpec should be null for non-matching key format"); + } + + @Test + void testParseCorruptedPkcs1KeyData() { + String corruptedKey = """ + -----BEGIN RSA PRIVATE KEY----- + MIIBOgIBAAJBAK5Erl8asdk== // corrupted base64 data + -----END RSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(corruptedKey, null); + assertNull(pk, "Parsed private key should be null for corrupted PKCS1 key data"); + } + + @Test + void testParsePkcs1KeyWithExcessiveWhitespace() { + String keyWithWhitespace = """ + -----BEGIN RSA PRIVATE KEY----- + + MIIBOgIBAAJBAK5Erl8a+lFsA8MPsh9aL9F+NfgmHNkGr/H0X0KdD/YU== + + -----END RSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(keyWithWhitespace, null); + assertNull(pk, "Parsed private key should be null for PKCS1 key with excessive whitespace"); + } + + @Test + void testParseNonRsaPrivateKey() { + String nonRsaKey = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZC5aj6qwMF0l4V9+ + E/Ns9js0Jx/vCEyXOg9T/MSTwRKhRANCAASFiBQ7XOkEHVFhzdL//o7aEdDmd0I5 + KUYl3ofGdhduE5F3YoxftD0YrPrk73dbmZZKqzHpD6nG7T8PzYpGpB4L + -----END PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(nonRsaKey, null); + assertNull(pk, "Parsed private key should be null for non-RSA private key data"); + } + + @Test + void testParsePkcs1KeyWithIncorrectHeaderOrder() { + String invalidHeaderOrderKey = """ + -----END RSA PRIVATE KEY----- + MIIBOgIBAAJBAK5Erl8a+lFsA8MPsh9aL9F+NfgmHNkGr/H0X0KdD/YU== + -----BEGIN RSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(invalidHeaderOrderKey, null); + assertNull(pk, "Parsed private key should be null for PKCS1 key with incorrect header order"); + } + + @Test + void testParseUnsupportedAlgorithm() { + String dsaPrivateKey = """ + -----BEGIN DSA PRIVATE KEY----- + MIIBugIBAAKBgQDGRn7MQjFl+hLehRihs14kn5PHKeMThCbxwU82Wl5uCk6JX/YK + K+dzpf8ZkVPoMc1kZyMCUVYmj4nnIqbi7dBnL/NL9ixv9A5OwOwRVFYmXwvfr9dK + -----END DSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(dsaPrivateKey, null); + assertNull(pk, "Parsed private key should be null for unsupported algorithm"); + } + + @Test + void testParsePkcs1KeyWithExtraDataAroundPemHeaders() { + String keyWithExtraData = """ + Some extra data before the key + -----BEGIN RSA PRIVATE KEY----- + MIIBOgIBAAJBAK5Erl8a+lFsA8MPsh9aL9F+NfgmHNkGr/H0X0KdD/YU== + -----END RSA PRIVATE KEY----- + Some extra data after the key + """; + PrivateKey pk = parser.getKey(keyWithExtraData, null); + assertNull(pk, "Parsed private key should be null even with extra data around the PEM headers"); + } + + @Test + void testParseUnsupportedKeySize() { + String smallKey = """ + -----BEGIN RSA PRIVATE KEY----- + MIIBAgIBAAIBAAIBAA== + -----END RSA PRIVATE KEY----- + """; + PrivateKey pk = parser.getKey(smallKey, null); + assertNull(pk, "Parsed private key should be null for unsupported key size"); + } + + @Test + void testParsePkcs1KeyMatchesOriginal() throws Exception { + // Parse the PEM-encoded key + PrivateKey parsedPrivateKey = parser.getKey(key, null); + assertNotNull(parsedPrivateKey, "Parsed private key should not be null for valid PKCS1 key"); + + // Check if parsed private key matches original private key's properties + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKeySpec originalSpec = keyFactory.getKeySpec(originalPrivateKey, RSAPrivateCrtKeySpec.class); + RSAPrivateCrtKeySpec parsedSpec = keyFactory.getKeySpec(parsedPrivateKey, RSAPrivateCrtKeySpec.class); + + // Compare essential RSA parameters + assertEquals(originalSpec.getModulus(), parsedSpec.getModulus(), "Modulus should match"); + assertEquals(originalSpec.getPrivateExponent(), parsedSpec.getPrivateExponent(), "Private exponent should match"); + assertEquals(originalSpec.getPublicExponent(), parsedSpec.getPublicExponent(), "Public exponent should match"); + assertEquals(originalSpec.getPrimeP(), parsedSpec.getPrimeP(), "Prime P should match"); + assertEquals(originalSpec.getPrimeQ(), parsedSpec.getPrimeQ(), "Prime Q should match"); + assertEquals(originalSpec.getPrimeExponentP(), parsedSpec.getPrimeExponentP(), "Prime Exponent P should match"); + assertEquals(originalSpec.getPrimeExponentQ(), parsedSpec.getPrimeExponentQ(), "Prime Exponent Q should match"); + assertEquals(originalSpec.getCrtCoefficient(), parsedSpec.getCrtCoefficient(), "CRT Coefficient should match"); + } +} \ No newline at end of file diff --git a/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS8ParserTest.java b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS8ParserTest.java new file mode 100644 index 0000000..c4c0e3f --- /dev/null +++ b/private-key-pem-parser/src/test/java/io/smallrye/certs/pem/parsers/PKCS8ParserTest.java @@ -0,0 +1,84 @@ +package io.smallrye.certs.pem.parsers; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PKCS8ParserTest { + + private PKCS8Parser parser; + private PrivateKey originalPrivateKey; + private String pkcs8PemKey; + + @BeforeEach + void setup() throws Exception { + // Add BouncyCastle provider for additional algorithms if needed + Security.addProvider(new BouncyCastleProvider()); + + // Initialize the parser + parser = new PKCS8Parser(); + + // Step 1: Generate an RSA key pair and store the original private key + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + originalPrivateKey = keyPair.getPrivate(); + + // Step 2: Convert the private key to PKCS#8 format and encode it as a PEM string + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(originalPrivateKey.getEncoded()); + byte[] pkcs8EncodedKey = pkcs8KeySpec.getEncoded(); + String base64EncodedKey = Base64.getMimeEncoder().encodeToString(pkcs8EncodedKey); + pkcs8PemKey = "-----BEGIN PRIVATE KEY-----\n" + base64EncodedKey + "\n-----END PRIVATE KEY-----"; + } + + @Test + void testParseValidPkcs8Key() { + // Parse the PKCS8 PEM key using PKCS8Parser + PrivateKey parsedKey = parser.getKey(pkcs8PemKey, null); + assertNotNull(parsedKey, "Parsed private key should not be null for a valid PKCS8 PEM key"); + + // Verify algorithm and format + assertEquals(originalPrivateKey.getAlgorithm(), parsedKey.getAlgorithm(), + "Algorithm should match the original private key"); + assertEquals(originalPrivateKey.getFormat(), parsedKey.getFormat(), "Format should be PKCS#8"); + + // Verify the encoded key bytes to ensure exact match + assertArrayEquals(originalPrivateKey.getEncoded(), parsedKey.getEncoded(), + "Parsed key should match the original private key in encoding"); + } + + @Test + void testParseInvalidPkcs8KeyFormat() { + // Test that an invalid PEM format is handled gracefully and returns null + String invalidPemKey = "-----BEGIN PRIVATE KEY-----\nInvalidBase64Data==\n-----END PRIVATE KEY-----"; + PrivateKey parsedKey = parser.getKey(invalidPemKey, null); + assertNull(parsedKey, "Parsed private key should be null for an invalid PEM format"); + } + + @Test + void testParseNonPkcs8Key() { + // Test that a non-PKCS8 key (e.g., an encrypted or unsupported format) returns null + String nonPkcs8PemKey = """ + -----BEGIN ENCRYPTED PRIVATE KEY----- + MIIBvTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIu7HB4LKl0xgCAggA + ... + -----END ENCRYPTED PRIVATE KEY----- + """; + PrivateKey parsedKey = parser.getKey(nonPkcs8PemKey, null); + assertNull(parsedKey, "Parsed private key should be null for a non-PKCS8 format"); + } +} \ No newline at end of file