diff --git a/oauth2_http/java/com/google/auth/mtls/CertificateSourceUnavailableException.java b/oauth2_http/java/com/google/auth/mtls/CertificateSourceUnavailableException.java new file mode 100644 index 000000000..71d5b11d6 --- /dev/null +++ b/oauth2_http/java/com/google/auth/mtls/CertificateSourceUnavailableException.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import java.io.IOException; + +/** + * This exception is thrown by certificate providers in the Google auth library when the certificate + * source is unavailable. This means that the transport layer should move on to the next certificate + * source provider type. + */ +public class CertificateSourceUnavailableException extends IOException { + + /** + * Constructor with a message and throwable cause. + * + * @param message The detail message (which is saved for later retrieval by the {@link + * #getMessage()} method) + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). + * (A null value is permitted, and indicates that the cause is nonexistent or unknown.) + */ + public CertificateSourceUnavailableException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructor with a throwable cause. + * + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). + * (A null value is permitted, and indicates that the cause is nonexistent or unknown.) + */ + public CertificateSourceUnavailableException(Throwable cause) { + super(cause); + } + + /** + * Constructor with a message. + * + * @param message The detail message (which is saved for later retrieval by the {@link + * #getMessage()} method) + */ + public CertificateSourceUnavailableException(String message) { + super(message); + } +} diff --git a/oauth2_http/java/com/google/auth/mtls/WorkloadCertificateConfiguration.java b/oauth2_http/java/com/google/auth/mtls/WorkloadCertificateConfiguration.java new file mode 100644 index 000000000..5da318ff6 --- /dev/null +++ b/oauth2_http/java/com/google/auth/mtls/WorkloadCertificateConfiguration.java @@ -0,0 +1,101 @@ +/* + * Copyright 2025, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.json.gson.GsonFactory; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +class WorkloadCertificateConfiguration { + + private String certPath; + private String privateKeyPath; + + private static JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + private static JsonObjectParser parser = new JsonObjectParser(jsonFactory); + + WorkloadCertificateConfiguration(String certPath, String privateKeyPath) { + this.certPath = certPath; + this.privateKeyPath = privateKeyPath; + } + + String getCertPath() { + return certPath; + } + + String getPrivateKeyPath() { + return privateKeyPath; + } + + static WorkloadCertificateConfiguration fromCertificateConfigurationStream( + InputStream certConfigStream) throws IOException { + Preconditions.checkNotNull(certConfigStream); + + GenericJson fileContents = + parser.parseAndClose(certConfigStream, StandardCharsets.UTF_8, GenericJson.class); + + Map certConfigs = (Map) fileContents.get("cert_configs"); + if (certConfigs == null) { + throw new IllegalArgumentException( + "The cert_configs object must be provided in the certificate configuration file."); + } + + Map workloadConfig = (Map) certConfigs.get("workload"); + if (workloadConfig == null) { + // Throw a CertificateSourceUnavailableException because there is no workload cert source. + // This tells the transport layer that it should check for another certificate source type. + throw new CertificateSourceUnavailableException( + "A workload certificate configuration must be provided in the cert_configs object."); + } + + String certPath = (String) workloadConfig.get("cert_path"); + if (Strings.isNullOrEmpty(certPath)) { + throw new IllegalArgumentException( + "The cert_path field must be provided in the workload certificate configuration."); + } + + String privateKeyPath = (String) workloadConfig.get("key_path"); + if (Strings.isNullOrEmpty(privateKeyPath)) { + throw new IllegalArgumentException( + "The key_path field must be provided in the workload certificate configuration."); + } + + return new WorkloadCertificateConfiguration(certPath, privateKeyPath); + } +} diff --git a/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java new file mode 100644 index 000000000..2d5ea3c54 --- /dev/null +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -0,0 +1,198 @@ +/* + * Copyright 2025, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import com.google.api.client.util.SecurityUtils; +import com.google.common.base.Strings; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.security.KeyStore; +import java.util.Locale; + +/** + * This class provides certificate key stores to the Google Auth library transport layer via + * certificate configuration files. This is only meant to be used internally to Google Cloud + * libraries, and the public facing methods may be changed without notice, and have no guarantee of + * backwards compatability. + */ +public class X509Provider { + static final String CERTIFICATE_CONFIGURATION_ENV_VARIABLE = "GOOGLE_API_CERTIFICATE_CONFIG"; + static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; + static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + + private String certConfigPathOverride; + + /** + * Creates an X509 provider with an override path for the certificate configuration, bypassing the + * normal checks for the well known certificate configuration file path and environment variable. + * This is meant for internal Google Cloud usage and behavior may be changed without warning. + * + * @param certConfigPathOverride the path to read the certificate configuration from. + */ + public X509Provider(String certConfigPathOverride) { + this.certConfigPathOverride = certConfigPathOverride; + } + + /** + * Creates a new X.509 provider that will check the environment variable path and the well known + * Gcloud certificate configuration location. This is meant for internal Google Cloud usage and + * behavior may be changed without warning. + */ + public X509Provider() { + this(null); + } + + /** + * Finds the certificate configuration file, then builds a Keystore using the X.509 certificate + * and private key pointed to by the configuration. This will check the following locations in + * order. + * + *
    + *
  • The certificate config override path, if set. + *
  • The path pointed to by the "GOOGLE_API_CERTIFICATE_CONFIG" environment variable + *
  • The well known gcloud location for the certificate configuration file. + *
+ * + * @return a KeyStore containing the X.509 certificate specified by the certificate configuration. + * @throws IOException if there is an error retrieving the certificate configuration. + */ + public KeyStore getKeyStore() throws IOException { + + WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration(); + + InputStream certStream = null; + InputStream privateKeyStream = null; + SequenceInputStream certAndPrivateKeyStream = null; + try { + // Read the certificate and private key file paths into separate streams. + File certFile = new File(workloadCertConfig.getCertPath()); + File privateKeyFile = new File(workloadCertConfig.getPrivateKeyPath()); + certStream = createInputStream(certFile); + privateKeyStream = createInputStream(privateKeyFile); + + // Merge the two streams into a single stream. + certAndPrivateKeyStream = new SequenceInputStream(certStream, privateKeyStream); + + // Build a key store using the combined stream. + return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); + } catch (CertificateSourceUnavailableException e) { + // Throw the CertificateSourceUnavailableException without wrapping. + throw e; + } catch (Exception e) { + // Wrap all other exception types to an IOException. + throw new IOException(e); + } finally { + if (certStream != null) { + certStream.close(); + } + if (privateKeyStream != null) { + privateKeyStream.close(); + } + if (certAndPrivateKeyStream != null) { + certAndPrivateKeyStream.close(); + } + } + } + + private WorkloadCertificateConfiguration getWorkloadCertificateConfiguration() + throws IOException { + File certConfig; + if (this.certConfigPathOverride != null) { + certConfig = new File(certConfigPathOverride); + } else { + String envCredentialsPath = getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE); + if (!Strings.isNullOrEmpty(envCredentialsPath)) { + certConfig = new File(envCredentialsPath); + } else { + certConfig = getWellKnownCertificateConfigFile(); + } + } + InputStream certConfigStream = null; + try { + if (!isFile(certConfig)) { + // Path will be put in the message from the catch block below + throw new CertificateSourceUnavailableException("File does not exist."); + } + certConfigStream = createInputStream(certConfig); + return WorkloadCertificateConfiguration.fromCertificateConfigurationStream(certConfigStream); + } finally { + if (certConfigStream != null) { + certConfigStream.close(); + } + } + } + + /* + * Start of methods to allow overriding in the test code to isolate from the environment. + */ + boolean isFile(File file) { + return file.isFile(); + } + + InputStream createInputStream(File file) throws FileNotFoundException { + return new FileInputStream(file); + } + + String getEnv(String name) { + return System.getenv(name); + } + + String getOsName() { + return getProperty("os.name", "").toLowerCase(Locale.US); + } + + String getProperty(String property, String def) { + return System.getProperty(property, def); + } + /* + * End of methods to allow overriding in the test code to isolate from the environment. + */ + + private File getWellKnownCertificateConfigFile() { + File cloudConfigPath; + String envPath = getEnv("CLOUDSDK_CONFIG"); + if (envPath != null) { + cloudConfigPath = new File(envPath); + } else if (getOsName().indexOf("windows") >= 0) { + File appDataPath = new File(getEnv("APPDATA")); + cloudConfigPath = new File(appDataPath, CLOUDSDK_CONFIG_DIRECTORY); + } else { + File configPath = new File(getProperty("user.home", ""), ".config"); + cloudConfigPath = new File(configPath, CLOUDSDK_CONFIG_DIRECTORY); + } + return new File(cloudConfigPath, WELL_KNOWN_CERTIFICATE_CONFIG_FILE); + } +} diff --git a/oauth2_http/javatests/com/google/auth/mtls/WorkloadCertificateConfigurationTest.java b/oauth2_http/javatests/com/google/auth/mtls/WorkloadCertificateConfigurationTest.java new file mode 100644 index 000000000..70bb4294e --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/mtls/WorkloadCertificateConfigurationTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import java.io.IOException; +import java.io.InputStream; +import org.junit.Assert; +import org.junit.Test; + +public class WorkloadCertificateConfigurationTest { + + @Test + public void workloadCertificateConfig_fromStream_Succeeds() throws IOException { + String certPath = "cert.crt"; + String privateKeyPath = "key.crt"; + InputStream configStream = writeWorkloadCertificateConfigStream(certPath, privateKeyPath); + + WorkloadCertificateConfiguration config = + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(configStream); + assertNotNull(config); + } + + @Test + public void workloadCertificateConfig_fromStreamMissingCertPath_Fails() throws IOException { + String certPath = ""; + String privateKeyPath = "key.crt"; + InputStream configStream = writeWorkloadCertificateConfigStream(certPath, privateKeyPath); + + IllegalArgumentException exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(configStream)); + assertTrue( + exception + .getMessage() + .contains( + "The cert_path field must be provided in the workload certificate configuration.")); + } + + @Test + public void workloadCertificateConfig_fromStreamMissingPrivateKeyPath_Fails() throws IOException { + String certPath = "cert.crt"; + String privateKeyPath = ""; + InputStream configStream = writeWorkloadCertificateConfigStream(certPath, privateKeyPath); + + IllegalArgumentException exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(configStream)); + assertTrue( + exception + .getMessage() + .contains( + "The key_path field must be provided in the workload certificate configuration.")); + } + + @Test + public void workloadCertificateConfig_fromStreamMissingWorkload_Fails() throws IOException { + GenericJson json = new GenericJson(); + json.put("cert_configs", new GenericJson()); + InputStream configStream = TestUtils.jsonToInputStream(json); + + CertificateSourceUnavailableException exception = + Assert.assertThrows( + CertificateSourceUnavailableException.class, + () -> + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(configStream)); + assertTrue( + exception + .getMessage() + .contains( + "A workload certificate configuration must be provided in the cert_configs object.")); + } + + @Test + public void workloadCertificateConfig_fromStreamMissingCertConfig_Fails() throws IOException { + GenericJson json = new GenericJson(); + InputStream configStream = TestUtils.jsonToInputStream(json); + + IllegalArgumentException exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(configStream)); + assertTrue( + exception + .getMessage() + .contains( + "The cert_configs object must be provided in the certificate configuration file.")); + } + + static InputStream writeWorkloadCertificateConfigStream(String certPath, String privateKeyPath) + throws IOException { + GenericJson json = writeWorkloadCertificateConfigJson(certPath, privateKeyPath); + return TestUtils.jsonToInputStream(json); + } + + private static GenericJson writeWorkloadCertificateConfigJson( + String certPath, String privateKeyPath) { + GenericJson json = new GenericJson(); + json.put("version", 1); + GenericJson certConfigs = new GenericJson(); + GenericJson workloadConfig = new GenericJson(); + if (certPath != null) { + workloadConfig.put("cert_path", certPath); + } + if (privateKeyPath != null) { + workloadConfig.put("key_path", privateKeyPath); + } + certConfigs.put("workload", workloadConfig); + json.put("cert_configs", certConfigs); + return json; + } +} diff --git a/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java b/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java new file mode 100644 index 000000000..de016cf55 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2025, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.HashMap; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +public class X509ProviderTest { + + private static final String TEST_CERT = + "-----BEGIN CERTIFICATE-----\n" + + "MIICGzCCAYSgAwIBAgIIWrt6xtmHPs4wDQYJKoZIhvcNAQEFBQAwMzExMC8GA1UE\n" + + "AxMoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTAeFw0x\n" + + "MjEyMDExNjEwNDRaFw0yMjExMjkxNjEwNDRaMDMxMTAvBgNVBAMTKDEwMDkxMjA3\n" + + "MjY4NzguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20wgZ8wDQYJKoZIhvcNAQEB\n" + + "BQADgY0AMIGJAoGBAL1SdY8jTUVU7O4/XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQ\n" + + "GLW8Iftx9wfXe1zuaehJSgLcyCxazfyJoN3RiONBihBqWY6d3lQKqkgsRTNZkdFJ\n" + + "Wdzl/6CxhK9sojh2p0r3tydtv9iwq5fuuWIvtODtT98EgphhncQAqkKoF3zVAgMB\n" + + "AAGjODA2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM\n" + + "MAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4GBAD8XQEqzGePa9VrvtEGpf+R4\n" + + "fkxKbcYAzqYq202nKu0kfjhIYkYSBj6gi348YaxE64yu60TVl42l5HThmswUheW4\n" + + "uQIaq36JvwvsDP5Zoj5BgiNSnDAFQp+jJFBRUA5vooJKgKgMDf/r/DCOsbO6VJF1\n" + + "kWwa9n19NFiV0z3m6isj\n" + + "-----END CERTIFICATE-----\n"; + + private static final String TEST_PRIVATE_KEY = + "-----BEGIN PRIVATE KEY-----\n" + + "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAL1SdY8jTUVU7O4/\n" + + "XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQGLW8Iftx9wfXe1zuaehJSgLcyCxazfyJ\n" + + "oN3RiONBihBqWY6d3lQKqkgsRTNZkdFJWdzl/6CxhK9sojh2p0r3tydtv9iwq5fu\n" + + "uWIvtODtT98EgphhncQAqkKoF3zVAgMBAAECgYB51B9cXe4yiGTzJ4pOKpHGySAy\n" + + "sC1F/IjXt2eeD3PuKv4m/hL4l7kScpLx0+NJuQ4j8U2UK/kQOdrGANapB1ZbMZAK\n" + + "/q0xmIUzdNIDiGSoTXGN2mEfdsEpQ/Xiv0lyhYBBPC/K4sYIpHccnhSRQUZlWLLY\n" + + "lE5cFNKC9b7226mNvQJBAPt0hfCNIN0kUYOA9jdLtx7CE4ySGMPf5KPBuzPd8ty1\n" + + "fxaFm9PB7B76VZQYmHcWy8rT5XjoLJHrmGW1ZvP+iDsCQQDAvnKoarPOGb5iJfkq\n" + + "RrA4flf1TOlf+1+uqIOJ94959jkkJeb0gv/TshDnm6/bWn+1kJylQaKygCizwPwB\n" + + "Z84vAkA0Duur4YvsPJijoQ9YY1SGCagCcjyuUKwFOxaGpmyhRPIKt56LOJqpzyno\n" + + "fy8ReKa4VyYq4eZYT249oFCwMwIBAkAROPNF2UL3x5UbcAkznd1hLujtIlI4IV4L\n" + + "XUNjsJtBap7we/KHJq11XRPlniO4lf2TW7iji5neGVWJulTKS1xBAkAerktk4Hsw\n" + + "ErUaUG1s/d+Sgc8e/KMeBElV+NxGhcWEeZtfHMn/6VOlbzY82JyvC9OKC80A5CAE\n" + + "VUV6b25kqrcu\n" + + "-----END PRIVATE KEY-----"; + + @Test + public void x509Provider_fileDoesntExist_throws() { + String certConfigPath = "badfile.txt"; + X509Provider testProvider = new TestX509Provider(certConfigPath); + String expectedErrorMessage = "File does not exist."; + + CertificateSourceUnavailableException exception = + Assert.assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore); + assertTrue(exception.getMessage().contains(expectedErrorMessage)); + } + + @Test + public void x509Provider_emptyFile_throws() { + String certConfigPath = "certConfig.txt"; + InputStream certConfigStream = new ByteArrayInputStream("".getBytes()); + TestX509Provider testProvider = new TestX509Provider(certConfigPath); + testProvider.addFile(certConfigPath, certConfigStream); + String expectedErrorMessage = "no JSON input found"; + + IllegalArgumentException exception = + Assert.assertThrows(IllegalArgumentException.class, testProvider::getKeyStore); + assertTrue(exception.getMessage().contains(expectedErrorMessage)); + } + + @Test + public void x509Provider_succeeds() throws IOException, KeyStoreException, CertificateException { + String certConfigPath = "certConfig.txt"; + String certPath = "cert.crt"; + String keyPath = "key.crt"; + InputStream certConfigStream = + WorkloadCertificateConfigurationTest.writeWorkloadCertificateConfigStream( + certPath, keyPath); + + TestX509Provider testProvider = new TestX509Provider(certConfigPath); + testProvider.addFile(certConfigPath, certConfigStream); + testProvider.addFile(certPath, new ByteArrayInputStream(TEST_CERT.getBytes())); + testProvider.addFile(keyPath, new ByteArrayInputStream(TEST_PRIVATE_KEY.getBytes())); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate expectedCert = + cf.generateCertificate(new ByteArrayInputStream(TEST_CERT.getBytes())); + + // Assert that the store has the expected certificate and only the expected certificate. + KeyStore store = testProvider.getKeyStore(); + assertEquals(1, store.size()); + assertNotNull(store.getCertificateAlias(expectedCert)); + } + + @Test + public void x509Provider_succeeds_withEnvVariable() + throws IOException, KeyStoreException, CertificateException { + String certConfigPath = "certConfig.txt"; + String certPath = "cert.crt"; + String keyPath = "key.crt"; + InputStream certConfigStream = + WorkloadCertificateConfigurationTest.writeWorkloadCertificateConfigStream( + certPath, keyPath); + + TestX509Provider testProvider = new TestX509Provider(); + testProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", certConfigPath); + testProvider.addFile(certConfigPath, certConfigStream); + testProvider.addFile(certPath, new ByteArrayInputStream(TEST_CERT.getBytes())); + testProvider.addFile(keyPath, new ByteArrayInputStream(TEST_PRIVATE_KEY.getBytes())); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate expectedCert = + cf.generateCertificate(new ByteArrayInputStream(TEST_CERT.getBytes())); + + // Assert that the store has the expected certificate and only the expected certificate. + KeyStore store = testProvider.getKeyStore(); + assertEquals(1, store.size()); + assertNotNull(store.getCertificateAlias(expectedCert)); + } + + @Test + public void x509Provider_succeeds_withWellKnownPath() + throws IOException, KeyStoreException, CertificateException { + String certConfigPath = "certConfig.txt"; + String certPath = "cert.crt"; + String keyPath = "key.crt"; + InputStream certConfigStream = + WorkloadCertificateConfigurationTest.writeWorkloadCertificateConfigStream( + certPath, keyPath); + + TestX509Provider testProvider = new TestX509Provider(); + testProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", certConfigPath); + testProvider.addFile(certConfigPath, certConfigStream); + testProvider.addFile(certPath, new ByteArrayInputStream(TEST_CERT.getBytes())); + testProvider.addFile(keyPath, new ByteArrayInputStream(TEST_PRIVATE_KEY.getBytes())); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate expectedCert = + cf.generateCertificate(new ByteArrayInputStream(TEST_CERT.getBytes())); + + // Assert that the store has the expected certificate and only the expected certificate. + KeyStore store = testProvider.getKeyStore(); + assertEquals(1, store.size()); + assertNotNull(store.getCertificateAlias(expectedCert)); + } + + static class TestX509Provider extends X509Provider { + private final Map files; + private final Map variables; + private final Map properties; + + TestX509Provider() { + this(null); + } + + TestX509Provider(String filePathOverride) { + super(filePathOverride); + this.files = new HashMap<>(); + this.variables = new HashMap<>(); + this.properties = new HashMap<>(); + } + + void addFile(String file, InputStream stream) { + files.put(file, stream); + } + + @Override + String getEnv(String name) { + return variables.get(name); + } + + void setEnv(String name, String value) { + variables.put(name, value); + } + + @Override + String getProperty(String property, String def) { + String value = properties.get(property); + return value == null ? def : value; + } + + @Override + boolean isFile(File file) { + return files.containsKey(file.getPath()); + } + + @Override + InputStream createInputStream(File file) throws FileNotFoundException { + InputStream stream = files.get(file.getPath()); + if (stream == null) { + throw new FileNotFoundException(file.getPath()); + } + return stream; + } + } +}