diff --git a/core/src/main/java/io/grpc/util/CertificateUtils.java b/core/src/main/java/io/grpc/util/CertificateUtils.java new file mode 100644 index 0000000000..980862d383 --- /dev/null +++ b/core/src/main/java/io/grpc/util/CertificateUtils.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.util; + +import com.google.common.io.BaseEncoding; +import io.grpc.ExperimentalApi; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Collection; + +/** + * Contains certificate/key PEM file utility method(s). + */ +@ExperimentalApi("https://github.com/grpc/grpc-java/issues/8024") +public final class CertificateUtils { + /** + * Generates X509Certificate array from a PEM file. + * The PEM file should contain one or more items in Base64 encoding, each with + * plain-text headers and footers + * (e.g. -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----). + * + * @param inputStream is a {@link InputStream} from the certificate files + */ + public static X509Certificate[] getX509Certificates(InputStream inputStream) + throws CertificateException { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + Collection certs = factory.generateCertificates(inputStream); + return certs.toArray(new X509Certificate[0]); + } + + /** + * Generates a {@link PrivateKey} from a PEM file. + * The key should be PKCS #8 formatted. + * The PEM file should contain one item in Base64 encoding, with plain-text headers and footers + * (e.g. -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----). + * + * @param inputStream is a {@link InputStream} from the private key file + */ + public static PrivateKey getPrivateKey(InputStream inputStream) + throws UnsupportedEncodingException, IOException, NoSuchAlgorithmException, + InvalidKeySpecException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + String line; + while ((line = reader.readLine()) != null) { + if ("-----BEGIN PRIVATE KEY-----".equals(line)) { + break; + } + } + StringBuilder keyContent = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if ("-----END PRIVATE KEY-----".equals(line)) { + break; + } + keyContent.append(line); + } + byte[] decodedKeyBytes = BaseEncoding.base64().decode(keyContent.toString()); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKeyBytes); + return keyFactory.generatePrivate(keySpec); + } +} + diff --git a/core/src/test/java/io/grpc/util/CertificateUtilsTest.java b/core/src/test/java/io/grpc/util/CertificateUtilsTest.java new file mode 100644 index 0000000000..5fa93d5b85 --- /dev/null +++ b/core/src/test/java/io/grpc/util/CertificateUtilsTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.util; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Charsets; +import io.grpc.internal.testing.TestUtils; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link CertificateUtilsTest}. */ +@RunWith(JUnit4.class) +public class CertificateUtilsTest { + public static final String SERVER_0_PEM_FILE = "server0.pem"; + public static final String SERVER_0_KEY_FILE = "server0.key"; + public static final String CA_PEM_FILE = "ca.pem"; + public static final String BAD_PEM_FORMAT = "This is a bad key pem format"; + public static final String BAD_PEM_CONTENT = "----BEGIN PRIVATE KEY-----\n" + + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDvdzKDTYvRgjBO\n" + + "-----END PRIVATE KEY-----"; + + @Test + public void readPemCertFile() throws CertificateException, IOException { + InputStream in = TestUtils.class.getResourceAsStream("/certs/" + SERVER_0_PEM_FILE); + X509Certificate[] cert = CertificateUtils.getX509Certificates(in); + assertThat(cert.length).isEqualTo(1); + // Checks some information on the test certificate. + assertThat(cert[0].getSerialNumber()).isEqualTo(new BigInteger( + "6c97d344427a93affea089d6855d4ed63dd94f38", 16)); + assertThat(cert[0].getSubjectDN().getName()).isEqualTo( + "CN=*.test.google.com.au, O=Internet Widgits Pty Ltd, ST=Some-State, C=AU"); + } + + @Test + public void readPemKeyFile() throws Exception { + InputStream in = TestUtils.class.getResourceAsStream("/certs/" + SERVER_0_KEY_FILE); + PrivateKey key = CertificateUtils.getPrivateKey(in); + // Checks some information on the test key. + assertThat(key.getAlgorithm()).isEqualTo("RSA"); + assertThat(key.getFormat()).isEqualTo("PKCS#8"); + } + + @Test + public void readCaPemFile() throws CertificateException, IOException { + InputStream in = TestUtils.class.getResourceAsStream("/certs/" + CA_PEM_FILE); + X509Certificate[] cert = CertificateUtils.getX509Certificates(in); + assertThat(cert.length).isEqualTo(1); + // Checks some information on the test certificate. + assertThat(cert[0].getSerialNumber()).isEqualTo(new BigInteger( + "5ab3f456f1dccbe2cfe94b9836d88bf600610f9a", 16)); + assertThat(cert[0].getSubjectDN().getName()).isEqualTo( + "CN=testca, O=Internet Widgits Pty Ltd, ST=Some-State, C=AU"); + } + + @Test + public void readBadFormatKeyFile() throws Exception { + InputStream in = new ByteArrayInputStream(BAD_PEM_FORMAT.getBytes(Charsets.UTF_8)); + try { + CertificateUtils.getPrivateKey(in); + Assert.fail("no exception thrown"); + } catch (InvalidKeySpecException expected) { + // The error messages for OpenJDK 11 and 8 are different, and for Windows it will generate a + // different exception, so we only check if a general exception is thrown. + } + } + + @Test + public void readBadContentKeyFile() { + InputStream in = new ByteArrayInputStream(BAD_PEM_CONTENT.getBytes(Charsets.UTF_8)); + try { + CertificateUtils.getPrivateKey(in); + Assert.fail("no exception thrown"); + } catch (Exception expected) { + // The error messages for OpenJDK 11 and 8 are different, and for Windows it will generate a + // different exception, so we only check if a general exception is thrown. + } + } + +}