From fafa3ac82ed7920fb29a2851898d38a3437b6a55 Mon Sep 17 00:00:00 2001 From: sagnghos Date: Fri, 18 Sep 2026 11:11:18 +0000 Subject: [PATCH] feat(spanner): Support Dynamic Certificate/Key rotation in Spanner Omni --- .../google/cloud/spanner/SpannerOptions.java | 51 +++- .../spanner/connection/ConnectionOptions.java | 24 ++ .../connection/ConnectionProperties.java | 9 + .../cloud/spanner/connection/SpannerPool.java | 7 + .../cloud/spanner/omni/DynamicKeyManager.java | 251 ++++++++++++++++++ .../spanner/omni/DynamicTrustManager.java | 246 +++++++++++++++++ .../spanner/testing/SpannerOmniHelper.java | 9 + .../cloud/spanner/SpannerOptionsTest.java | 50 ++++ .../connection/ConnectionOptionsTest.java | 27 ++ .../spanner/connection/SpannerPoolTest.java | 45 ++++ .../spanner/omni/DynamicKeyManagerTest.java | 136 ++++++++++ .../spanner/omni/DynamicTrustManagerTest.java | 138 ++++++++++ 12 files changed, 980 insertions(+), 13 deletions(-) create mode 100644 java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicKeyManager.java create mode 100644 java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicTrustManager.java create mode 100644 java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicKeyManagerTest.java create mode 100644 java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicTrustManagerTest.java diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index ca1f9abec4f0..ee7b193e2513 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -53,6 +53,8 @@ import com.google.cloud.spanner.admin.database.v1.stub.DatabaseAdminStubSettings; import com.google.cloud.spanner.admin.instance.v1.InstanceAdminSettings; import com.google.cloud.spanner.admin.instance.v1.stub.InstanceAdminStubSettings; +import com.google.cloud.spanner.omni.DynamicKeyManager; +import com.google.cloud.spanner.omni.DynamicTrustManager; import com.google.cloud.spanner.omni.SpannerOmniCredentials; import com.google.cloud.spanner.spi.SpannerRpcFactory; import com.google.cloud.spanner.spi.v1.ChannelEndpointCacheFactory; @@ -85,6 +87,7 @@ import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import io.opencensus.trace.Tracing; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; @@ -941,14 +944,14 @@ protected SpannerOptions(Builder builder) { transportChannelExecutorThreadNameFormat = builder.transportChannelExecutorThreadNameFormat; channelProvider = builder.channelProvider; channelEndpointCacheFactory = builder.channelEndpointCacheFactory; - if (builder.mTLSContext != null) { + if (builder.omniSslContext != null) { channelConfigurator = channelBuilder -> { if (builder.channelConfigurator != null) { channelBuilder = builder.channelConfigurator.apply(channelBuilder); } if (channelBuilder instanceof NettyChannelBuilder) { - ((NettyChannelBuilder) channelBuilder).sslContext(builder.mTLSContext); + ((NettyChannelBuilder) channelBuilder).sslContext(builder.omniSslContext); } return channelBuilder; }; @@ -1292,6 +1295,13 @@ public GoogleCredentials getDefaultSpannerOmniCredentials() { public static class Builder extends ServiceOptions.Builder { private static Builder prepareBuilder(Builder builder) { + if (builder.sslContextBuilder != null) { + try { + builder.omniSslContext = builder.sslContextBuilder.build(); + } catch (Exception e) { + throw SpannerExceptionFactory.asSpannerException(e); + } + } if (builder.instanceType == InstanceType.OMNI) { builder.enableBuiltInMetrics = false; builder.setProjectId(SPANNER_OMNI_PROJECT_ID); @@ -1314,7 +1324,7 @@ private static Builder prepareBuilder(Builder builder) { } if (builder.credentials instanceof SpannerOmniCredentials) { ((SpannerOmniCredentials) builder.credentials) - .initChannel(builder.usePlainText, builder.mTLSContext); + .initChannel(builder.usePlainText, builder.omniSslContext); } } else { if (builder.username != null || builder.secretBytes != null) { @@ -1399,7 +1409,8 @@ private static Builder prepareBuilder(Builder builder) { private MetricsProvider metricsProvider = DefaultMetricsProvider.INSTANCE; private boolean enableLocationApi = SpannerOptions.environment.isEnableLocationApi(); private String monitoringHost = SpannerOptions.environment.getMonitoringHost(); - private SslContext mTLSContext = null; + private SslContextBuilder sslContextBuilder = null; + private SslContext omniSslContext = null; private boolean usePlainText = false; private TransactionOptions defaultTransactionOptions = TransactionOptions.getDefaultInstance(); private RequestOptions.ClientContext clientContext; @@ -2240,21 +2251,35 @@ public Builder setEmulatorHost(String emulatorHost) { /** * Configures mTLS authentication using the provided client certificate and key files. mTLS via - * useClientCert is only supported for Spanner Omni instances. + * useClientCert is only supported for Spanner Omni instances. Certificates and keys are loaded + * dynamically and reloaded automatically when rotated on disk. * * @param clientCertificate Path to the client certificate file. * @param clientCertificateKey Path to the client private key file. - * @throws SpannerException If an error occurs while configuring the mTLS context */ public Builder useClientCert(String clientCertificate, String clientCertificateKey) { - try { - this.mTLSContext = - GrpcSslContexts.forClient() - .keyManager(new File(clientCertificate), new File(clientCertificateKey)) - .build(); - } catch (Exception e) { - throw SpannerExceptionFactory.asSpannerException(e); + Preconditions.checkNotNull(clientCertificate, "clientCertificate cannot be null"); + Preconditions.checkNotNull(clientCertificateKey, "clientCertificateKey cannot be null"); + if (this.sslContextBuilder == null) { + this.sslContextBuilder = GrpcSslContexts.forClient(); + } + this.sslContextBuilder.keyManager( + new DynamicKeyManager(new File(clientCertificate), new File(clientCertificateKey))); + return this; + } + + /** + * Configures the server root CA certificate for SSL/TLS authentication. The CA certificate is + * loaded dynamically and reloaded automatically when rotated on disk. + * + * @param caCertificate Path to the server root CA certificate file. + */ + public Builder setCaCertificate(String caCertificate) { + Preconditions.checkNotNull(caCertificate, "caCertificate cannot be null"); + if (this.sslContextBuilder == null) { + this.sslContextBuilder = GrpcSslContexts.forClient(); } + this.sslContextBuilder.trustManager(new DynamicTrustManager(new File(caCertificate))); return this; } diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java index 00d616c53a6b..1237e88170eb 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java @@ -19,6 +19,7 @@ import static com.google.cloud.spanner.connection.ConnectionProperties.AUTOCOMMIT; import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_CONFIG_EMULATOR; import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_PARTITION_MODE; +import static com.google.cloud.spanner.connection.ConnectionProperties.CA_CERTIFICATE; import static com.google.cloud.spanner.connection.ConnectionProperties.CHANNEL_PROVIDER; import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_CERTIFICATE; import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_KEY; @@ -168,6 +169,7 @@ public class ConnectionOptions { static final String DEFAULT_CREDENTIALS = null; static final String DEFAULT_CLIENT_CERTIFICATE = null; static final String DEFAULT_CLIENT_KEY = null; + static final String DEFAULT_CA_CERTIFICATE = null; static final String DEFAULT_OAUTH_TOKEN = null; static final Integer DEFAULT_MIN_SESSIONS = null; static final Integer DEFAULT_MAX_SESSIONS = null; @@ -242,6 +244,9 @@ public class ConnectionOptions { /** Client key path to establish mTLS */ static final String CLIENT_KEY_PROPERTY_NAME = "clientKey"; + /** Server root CA certificate path for SSL/TLS */ + static final String CA_CERTIFICATE_PROPERTY_NAME = "caCertificate"; + /** Name of the 'autocommit' connection property. */ public static final String AUTOCOMMIT_PROPERTY_NAME = "autocommit"; @@ -676,6 +681,21 @@ public Builder setType(SpannerOptions.InstanceType instanceType) { return this; } + public Builder setClientCertificate(String clientCertificate) { + setConnectionPropertyValue(CLIENT_CERTIFICATE, clientCertificate); + return this; + } + + public Builder setClientCertificateKey(String clientCertificateKey) { + setConnectionPropertyValue(CLIENT_KEY, clientCertificateKey); + return this; + } + + public Builder setCaCertificate(String caCertificate) { + setConnectionPropertyValue(CA_CERTIFICATE, caCertificate); + return this; + } + /** * @return the {@link ConnectionOptions} */ @@ -1300,6 +1320,10 @@ String getClientCertificateKey() { return getInitialConnectionPropertyValue(CLIENT_KEY); } + String getCaCertificate() { + return getInitialConnectionPropertyValue(CA_CERTIFICATE); + } + /** * The (custom) user agent string to use for this connection. If null, then the * default JDBC user agent string will be used. diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java index d501ab11b138..16dc4e22553c 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java @@ -22,6 +22,7 @@ import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_PARTITION_MODE_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.BATCH_DML_UPDATE_COUNT_PROPERTY_NAME; +import static com.google.cloud.spanner.connection.ConnectionOptions.CA_CERTIFICATE_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CHANNEL_PROVIDER_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_CERTIFICATE_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_KEY_PROPERTY_NAME; @@ -42,6 +43,7 @@ import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_PARTITION_MODE; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_BATCH_DML_UPDATE_COUNT; +import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CA_CERTIFICATE; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CHANNEL_PROVIDER; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_CERTIFICATE; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_KEY; @@ -329,6 +331,13 @@ public class ConnectionProperties { DEFAULT_CLIENT_KEY, StringValueConverter.INSTANCE, Context.STARTUP); + static final ConnectionProperty CA_CERTIFICATE = + create( + CA_CERTIFICATE_PROPERTY_NAME, + "Specifies the file path to the server root CA certificate for SSL/TLS validation.", + DEFAULT_CA_CERTIFICATE, + StringValueConverter.INSTANCE, + Context.STARTUP); static final ConnectionProperty CREDENTIALS_URL = create( CREDENTIALS_PROPERTY_NAME, diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java index 785d2c80cd3e..19772fba973e 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java @@ -176,6 +176,7 @@ static class SpannerPoolKey { private final boolean enableEndToEndTracing; private final String clientCertificate; private final String clientCertificateKey; + private final String caCertificate; private final SpannerOptions.InstanceType instanceType; private final Boolean enableDirectAccess; private final String universeDomain; @@ -221,6 +222,7 @@ private SpannerPoolKey(ConnectionOptions options) throws IOException { this.enableEndToEndTracing = options.isEndToEndTracingEnabled(); this.clientCertificate = options.getClientCertificate(); this.clientCertificateKey = options.getClientCertificateKey(); + this.caCertificate = options.getCaCertificate(); this.instanceType = options.getInstanceType(); this.enableDirectAccess = options.isEnableDirectAccess(); this.universeDomain = options.getUniverseDomain(); @@ -261,6 +263,7 @@ public boolean equals(Object o) { && Objects.equals(this.enableEndToEndTracing, other.enableEndToEndTracing) && Objects.equals(this.clientCertificate, other.clientCertificate) && Objects.equals(this.clientCertificateKey, other.clientCertificateKey) + && Objects.equals(this.caCertificate, other.caCertificate) && Objects.equals(this.instanceType, other.instanceType) && Objects.equals(this.enableDirectAccess, other.enableDirectAccess) && Objects.equals(this.universeDomain, other.universeDomain) @@ -296,6 +299,7 @@ public int hashCode() { this.enableEndToEndTracing, this.clientCertificate, this.clientCertificateKey, + this.caCertificate, this.instanceType, this.enableDirectAccess, this.universeDomain, @@ -540,6 +544,9 @@ Spanner createSpanner(SpannerPoolKey key, ConnectionOptions options) { if (key.clientCertificate != null && key.clientCertificateKey != null) { builder.useClientCert(key.clientCertificate, key.clientCertificateKey); } + if (key.caCertificate != null) { + builder.setCaCertificate(key.caCertificate); + } if (key.instanceType != null) { builder.setType(key.instanceType); } diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicKeyManager.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicKeyManager.java new file mode 100644 index 000000000000..130e472071f3 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicKeyManager.java @@ -0,0 +1,251 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.cloud.spanner.omni; + +import com.google.api.core.InternalApi; +import com.google.common.base.Preconditions; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.Principal; +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.PKCS8EncodedKeySpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.util.Base64; +import java.util.Collection; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import org.bouncycastle.asn1.pkcs.RSAPrivateKey; + +/** + * An {@link X509ExtendedKeyManager} that dynamically reloads client certificates and private keys + * from disk whenever the underlying files are modified or rotated. + */ +@InternalApi +public class DynamicKeyManager extends X509ExtendedKeyManager { + private static final Logger logger = Logger.getLogger(DynamicKeyManager.class.getName()); + private static final String CLIENT_ALIAS = "client"; + + private final File certFile; + private final File keyFile; + + private static class KeyMaterial { + final long certLastModified; + final long certLength; + final long keyLastModified; + final long keyLength; + final X509Certificate[] certificateChain; + final PrivateKey privateKey; + + KeyMaterial( + long certLastModified, + long certLength, + long keyLastModified, + long keyLength, + X509Certificate[] certificateChain, + PrivateKey privateKey) { + this.certLastModified = certLastModified; + this.certLength = certLength; + this.keyLastModified = keyLastModified; + this.keyLength = keyLength; + this.certificateChain = certificateChain; + this.privateKey = privateKey; + } + } + + private volatile KeyMaterial currentMaterial; + + public DynamicKeyManager(File certFile, File keyFile) { + this.certFile = Preconditions.checkNotNull(certFile, "certFile cannot be null"); + this.keyFile = Preconditions.checkNotNull(keyFile, "keyFile cannot be null"); + reloadMaterial(); + } + + private void checkAndReload() { + KeyMaterial existing = this.currentMaterial; + if (existing != null + && certFile.lastModified() == existing.certLastModified + && certFile.length() == existing.certLength + && keyFile.lastModified() == existing.keyLastModified + && keyFile.length() == existing.keyLength) { + return; + } + synchronized (this) { + existing = this.currentMaterial; + if (existing != null + && certFile.lastModified() == existing.certLastModified + && certFile.length() == existing.certLength + && keyFile.lastModified() == existing.keyLastModified + && keyFile.length() == existing.keyLength) { + return; + } + try { + reloadMaterial(); + } catch (Exception e) { + logger.log( + Level.WARNING, + "Failed to reload rotated client certificate/key from disk, retaining current material", + e); + } + } + } + + private void reloadMaterial() { + try { + long certMod = certFile.lastModified(); + long certLen = certFile.length(); + long keyMod = keyFile.lastModified(); + long keyLen = keyFile.length(); + + byte[] certBytes = Files.readAllBytes(certFile.toPath()); + byte[] keyBytes = Files.readAllBytes(keyFile.toPath()); + + X509Certificate[] chain = parseCertificates(certBytes); + PrivateKey key = parsePrivateKey(keyBytes); + + this.currentMaterial = new KeyMaterial(certMod, certLen, keyMod, keyLen, chain, key); + } catch (Exception e) { + if (this.currentMaterial != null) { + logger.log( + Level.WARNING, + "Error reloading client certificate or key, falling back to cached credentials", + e); + } else { + throw new RuntimeException("Failed to initialize client certificate/key", e); + } + } + } + + private static X509Certificate[] parseCertificates(byte[] certBytes) throws CertificateException { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certs = + cf.generateCertificates(new ByteArrayInputStream(certBytes)); + if (certs == null || certs.isEmpty()) { + throw new CertificateException("No certificates found in certificate file"); + } + return certs.toArray(new X509Certificate[0]); + } + + private static PrivateKey parsePrivateKey(byte[] keyBytes) throws Exception { + String keyStr = new String(keyBytes, StandardCharsets.US_ASCII); + if (keyStr.contains("-----BEGIN RSA PRIVATE KEY-----")) { + byte[] der = + extractPemContent( + keyStr, "-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----"); + RSAPrivateKey rsaPrivKey = RSAPrivateKey.getInstance(der); + RSAPrivateCrtKeySpec keySpec = + new RSAPrivateCrtKeySpec( + rsaPrivKey.getModulus(), + rsaPrivKey.getPublicExponent(), + rsaPrivKey.getPrivateExponent(), + rsaPrivKey.getPrime1(), + rsaPrivKey.getPrime2(), + rsaPrivKey.getExponent1(), + rsaPrivKey.getExponent2(), + rsaPrivKey.getCoefficient()); + return KeyFactory.getInstance("RSA").generatePrivate(keySpec); + } + + byte[] der; + if (keyStr.contains("-----BEGIN PRIVATE KEY-----")) { + der = extractPemContent(keyStr, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); + } else { + try { + der = Base64.getMimeDecoder().decode(keyBytes); + } catch (IllegalArgumentException e) { + der = keyBytes; + } + } + + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + try { + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (Exception e) { + return KeyFactory.getInstance("EC").generatePrivate(spec); + } + } + + private static byte[] extractPemContent(String pem, String beginMarker, String endMarker) { + int start = pem.indexOf(beginMarker); + if (start < 0) { + throw new IllegalArgumentException("PEM does not contain marker: " + beginMarker); + } + start += beginMarker.length(); + int end = pem.indexOf(endMarker, start); + if (end < 0) { + throw new IllegalArgumentException("PEM does not contain marker: " + endMarker); + } + String base64 = pem.substring(start, end).replaceAll("\\s+", ""); + return Base64.getDecoder().decode(base64); + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + checkAndReload(); + return CLIENT_ALIAS; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + checkAndReload(); + return CLIENT_ALIAS; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + checkAndReload(); + KeyMaterial mat = this.currentMaterial; + return mat != null ? mat.certificateChain.clone() : null; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + checkAndReload(); + KeyMaterial mat = this.currentMaterial; + return mat != null ? mat.privateKey : null; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + checkAndReload(); + return new String[] {CLIENT_ALIAS}; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return null; + } +} diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicTrustManager.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicTrustManager.java new file mode 100644 index 000000000000..870aaaa1d458 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicTrustManager.java @@ -0,0 +1,246 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.cloud.spanner.omni; + +import com.google.api.core.InternalApi; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.net.Socket; +import java.nio.file.Files; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * An {@link X509ExtendedTrustManager} that dynamically reloads root CA certificates from disk + * whenever the certificate file is modified or rotated. + */ +@InternalApi +public class DynamicTrustManager extends X509ExtendedTrustManager { + private static final Logger logger = Logger.getLogger(DynamicTrustManager.class.getName()); + + private final File caCertFile; + + private static class TrustMaterial { + final long lastModified; + final long length; + final X509ExtendedTrustManager delegate; + + TrustMaterial(long lastModified, long length, X509ExtendedTrustManager delegate) { + this.lastModified = lastModified; + this.length = length; + this.delegate = delegate; + } + } + + private volatile TrustMaterial currentMaterial; + + public DynamicTrustManager(@Nullable File caCertFile) { + this.caCertFile = caCertFile; + reloadMaterial(); + } + + private void checkAndReload() { + if (this.caCertFile == null) { + return; + } + TrustMaterial existing = this.currentMaterial; + if (existing != null + && caCertFile.lastModified() == existing.lastModified + && caCertFile.length() == existing.length) { + return; + } + synchronized (this) { + existing = this.currentMaterial; + if (existing != null + && caCertFile.lastModified() == existing.lastModified + && caCertFile.length() == existing.length) { + return; + } + try { + reloadMaterial(); + } catch (Exception e) { + logger.log( + Level.WARNING, + "Failed to reload rotated CA certificate from disk, retaining previous material", + e); + } + } + } + + private void reloadMaterial() { + try { + if (this.caCertFile == null) { + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); + this.currentMaterial = new TrustMaterial(0, 0, findExtendedTrustManager(tmf)); + return; + } + + long mod = caCertFile.lastModified(); + long len = caCertFile.length(); + byte[] certBytes = Files.readAllBytes(caCertFile.toPath()); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certs = + cf.generateCertificates(new ByteArrayInputStream(certBytes)); + if (certs == null || certs.isEmpty()) { + throw new CertificateException("No certificates found in CA certificate file"); + } + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + int index = 0; + for (Certificate cert : certs) { + ks.setCertificateEntry("spanner-ca-" + (++index), cert); + } + + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + this.currentMaterial = new TrustMaterial(mod, len, findExtendedTrustManager(tmf)); + } catch (Exception e) { + if (this.currentMaterial != null) { + logger.log( + Level.WARNING, + "Error reloading CA certificate, falling back to cached trust manager", + e); + } else { + throw new RuntimeException("Failed to initialize CA certificate", e); + } + } + } + + private static X509ExtendedTrustManager findExtendedTrustManager(TrustManagerFactory tmf) + throws GeneralSecurityException { + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509ExtendedTrustManager) { + return (X509ExtendedTrustManager) tm; + } else if (tm instanceof X509TrustManager) { + return wrapTrustManager((X509TrustManager) tm); + } + } + throw new GeneralSecurityException("No X509TrustManager found in TrustManagerFactory"); + } + + private static X509ExtendedTrustManager wrapTrustManager(final X509TrustManager tm) { + return new X509ExtendedTrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + tm.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + tm.checkServerTrusted(chain, authType); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + tm.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + tm.checkServerTrusted(chain, authType); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + tm.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + tm.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return tm.getAcceptedIssuers(); + } + }; + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkClientTrusted(chain, authType, socket); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkServerTrusted(chain, authType, socket); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkClientTrusted(chain, authType, engine); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkServerTrusted(chain, authType, engine); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkAndReload(); + this.currentMaterial.delegate.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + checkAndReload(); + return this.currentMaterial.delegate.getAcceptedIssuers(); + } +} diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/SpannerOmniHelper.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/SpannerOmniHelper.java index 463c485ca737..3bad14c5cb13 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/SpannerOmniHelper.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/SpannerOmniHelper.java @@ -26,6 +26,7 @@ public class SpannerOmniHelper { private static final String USE_MTLS = "spanner.mtls"; private static final String CLIENT_CERT_PATH = "spanner.client_cert_path"; private static final String CLIENT_CERT_KEY_PATH = "spanner.client_cert_key_path"; + private static final String CA_CERT_PATH = "spanner.ca_cert_path"; private static final String USERNAME = "spanner.username"; private static final String PASSWORD = "spanner.password"; @@ -56,6 +57,10 @@ public static void appendSpannerOmniProperties(StringBuilder uri) { uri.append(";clientCertificate=").append(clientCertificate); uri.append(";clientKey=").append(clientKey); } + String caCertPath = System.getProperty(CA_CERT_PATH, ""); + if (!Strings.isNullOrEmpty(caCertPath)) { + uri.append(";caCertificate=").append(caCertPath); + } } public static boolean isMtlsSetup() { @@ -79,10 +84,14 @@ public static void setSpannerOmniOptions(SpannerOptions.Builder builder) { if (usePlainText) { builder.usePlainText(); } + String caCertPath = System.getProperty(CA_CERT_PATH, ""); if (isMtlsSetup()) { String clientCertificate = System.getProperty(CLIENT_CERT_PATH, ""); String clientKey = System.getProperty(CLIENT_CERT_KEY_PATH, ""); builder.useClientCert(clientCertificate, clientKey); } + if (!Strings.isNullOrEmpty(caCertPath)) { + builder.setCaCertificate(caCertPath); + } } } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java index 4b754c74027f..83003b144ce0 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java @@ -1684,4 +1684,54 @@ public ApiCallContext configure( customOptions.toBuilder().setCallContextConfigurator(null).build(); assertNull(clearedOptions.getCallContextConfigurator()); } + + @Test + public void testUseClientCertAndTrustCertificate() throws Exception { + io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate ssc = + new io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate("spanner.test"); + io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate ca = + new io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate("spanner.ca"); + + String certPath = ssc.certificate().getAbsolutePath(); + String keyPath = ssc.privateKey().getAbsolutePath(); + String caPath = ca.certificate().getAbsolutePath(); + + SpannerOptions options = + SpannerOptions.newBuilder() + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .setHost("https://localhost:1234") + .useClientCert(certPath, keyPath) + .setCaCertificate(caPath) + .build(); + + assertNotNull(options.getChannelConfigurator()); + + SpannerOptions fromBuilder = options.toBuilder().build(); + assertNotNull(fromBuilder.getChannelConfigurator()); + + // Test standalone setCaCertificate + SpannerOptions caOnlyOptions = + SpannerOptions.newBuilder() + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .setHost("https://localhost:1234") + .setCaCertificate(caPath) + .build(); + + assertNotNull(caOnlyOptions.getChannelConfigurator()); + + // Test setCaCertificate combined with login (username/password) + SpannerOptions loginWithCaOptions = + SpannerOptions.newBuilder() + .setProjectId("test-project") + .setType(SpannerOptions.InstanceType.OMNI) + .setHost("https://localhost:1234") + .setCaCertificate(caPath) + .login("test-user", "test-pass".toCharArray()) + .build(); + + assertTrue(loginWithCaOptions.getCredentials() instanceof SpannerOmniCredentials); + assertNotNull(loginWithCaOptions.getChannelConfigurator()); + } } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java index 38ab65b1523f..6722466ef495 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java @@ -1637,4 +1637,31 @@ public void testGrpcKeepAliveTimeoutOption() { .build(); assertNull(defaultOptions.getGrpcKeepAliveTimeout()); } + + @Test + public void testCertificateAndTrustOptions() { + ConnectionOptions optionsFromUri = + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database" + + "?clientCertificate=/path/to/client.crt;clientKey=/path/to/client.key;caCertificate=/path/to/ca.crt") + .setCredentials(NoCredentials.getInstance()) + .build(); + assertEquals("/path/to/client.crt", optionsFromUri.getClientCertificate()); + assertEquals("/path/to/client.key", optionsFromUri.getClientCertificateKey()); + assertEquals("/path/to/ca.crt", optionsFromUri.getCaCertificate()); + + ConnectionOptions optionsFromBuilder = + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database") + .setClientCertificate("/path/to/builder/client.crt") + .setClientCertificateKey("/path/to/builder/client.key") + .setCaCertificate("/path/to/builder/ca.crt") + .setCredentials(NoCredentials.getInstance()) + .build(); + assertEquals("/path/to/builder/client.crt", optionsFromBuilder.getClientCertificate()); + assertEquals("/path/to/builder/client.key", optionsFromBuilder.getClientCertificateKey()); + assertEquals("/path/to/builder/ca.crt", optionsFromBuilder.getCaCertificate()); + } } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java index 68951cc57618..91f671199620 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java @@ -848,4 +848,49 @@ public void testGrpcGcpSettings() { .setCredentials(NoCredentials.getInstance()) .build())); } + + @Test + public void testCertificateAndTrustSettings() { + SpannerPoolKey keyDefault = + SpannerPoolKey.of( + ConnectionOptions.newBuilder() + .setUri("cloudspanner:/projects/p/instances/i/databases/d") + .setCredentials(NoCredentials.getInstance()) + .build()); + SpannerPoolKey keyWithTrustCert1 = + SpannerPoolKey.of( + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/p/instances/i/databases/d?caCertificate=/path/to/ca1.crt") + .setCredentials(NoCredentials.getInstance()) + .build()); + SpannerPoolKey keyWithTrustCert2 = + SpannerPoolKey.of( + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/p/instances/i/databases/d?caCertificate=/path/to/ca2.crt") + .setCredentials(NoCredentials.getInstance()) + .build()); + SpannerPoolKey keyWithClientCert = + SpannerPoolKey.of( + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/p/instances/i/databases/d" + + "?clientCertificate=/path/to/client.crt;clientKey=/path/to/client.key;caCertificate=/path/to/ca1.crt") + .setCredentials(NoCredentials.getInstance()) + .build()); + + assertNotEquals(keyDefault, keyWithTrustCert1); + assertNotEquals(keyWithTrustCert1, keyWithTrustCert2); + assertNotEquals(keyWithTrustCert1, keyWithClientCert); + + assertEquals( + keyWithTrustCert1, + SpannerPoolKey.of( + ConnectionOptions.newBuilder() + .setUri( + "cloudspanner:/projects/p/instances/i/databases/d?caCertificate=/path/to/ca1.crt") + .setCredentials(NoCredentials.getInstance()) + .build())); + } } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicKeyManagerTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicKeyManagerTest.java new file mode 100644 index 000000000000..49aae9307415 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicKeyManagerTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.cloud.spanner.omni; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class DynamicKeyManagerTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testInitialLoadAndDynamicRotation() throws Exception { + SelfSignedCertificate ssc1 = new SelfSignedCertificate("spanner.test.1"); + File certFile = tempFolder.newFile("client.crt"); + File keyFile = tempFolder.newFile("client.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc1.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc1.privateKey().toPath())); + + DynamicKeyManager keyManager = new DynamicKeyManager(certFile, keyFile); + + String alias1 = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertNotNull(alias1); + assertEquals(alias1, keyManager.chooseEngineClientAlias(new String[] {"RSA"}, null, null)); + + X509Certificate[] chain1 = keyManager.getCertificateChain(alias1); + assertNotNull(chain1); + assertEquals(1, chain1.length); + assertEquals(ssc1.cert().getSubjectDN(), chain1[0].getSubjectDN()); + + PrivateKey pk1 = keyManager.getPrivateKey(alias1); + assertNotNull(pk1); + assertEquals(ssc1.key().getAlgorithm(), pk1.getAlgorithm()); + + String[] aliases1 = keyManager.getClientAliases("RSA", null); + assertNotNull(aliases1); + assertEquals(1, aliases1.length); + assertEquals(alias1, aliases1[0]); + + // Ensure lastModified timestamp changes upon rotation + Thread.sleep(1100); + + SelfSignedCertificate ssc2 = new SelfSignedCertificate("spanner.test.2"); + Files.write(certFile.toPath(), Files.readAllBytes(ssc2.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc2.privateKey().toPath())); + + String alias2 = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertNotNull(alias2); + + X509Certificate[] chain2 = keyManager.getCertificateChain(alias2); + assertNotNull(chain2); + assertEquals(ssc2.cert().getSubjectDN(), chain2[0].getSubjectDN()); + + PrivateKey pk2 = keyManager.getPrivateKey(alias2); + assertNotNull(pk2); + } + + @Test + public void testCorruptRotationFallsBackToPrevious() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.fallback"); + File certFile = tempFolder.newFile("client-fallback.crt"); + File keyFile = tempFolder.newFile("client-fallback.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc.privateKey().toPath())); + + DynamicKeyManager keyManager = new DynamicKeyManager(certFile, keyFile); + String aliasBefore = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertNotNull(aliasBefore); + + Thread.sleep(1100); + + // Overwrite certFile with corrupt bytes + Files.write(certFile.toPath(), "NOT A CERTIFICATE CONTENT".getBytes(StandardCharsets.UTF_8)); + + // DynamicKeyManager should catch reload error and retain previous material + String aliasAfter = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals(aliasBefore, aliasAfter); + assertNotNull(keyManager.getCertificateChain(aliasAfter)); + assertNotNull(keyManager.getPrivateKey(aliasAfter)); + } + + @Test + public void testNonExistentFileFailsInitialization() { + File nonExistentCert = new File(tempFolder.getRoot(), "missing.crt"); + File nonExistentKey = new File(tempFolder.getRoot(), "missing.key"); + + assertThrows( + RuntimeException.class, () -> new DynamicKeyManager(nonExistentCert, nonExistentKey)); + } + + @Test + public void testServerAliasesReturnNull() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.server"); + File certFile = tempFolder.newFile("server-test.crt"); + File keyFile = tempFolder.newFile("server-test.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc.privateKey().toPath())); + + DynamicKeyManager keyManager = new DynamicKeyManager(certFile, keyFile); + assertNull(keyManager.getServerAliases("RSA", null)); + assertNull(keyManager.chooseServerAlias("RSA", null, null)); + assertNull(keyManager.chooseEngineServerAlias("RSA", null, null)); + } +} diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicTrustManagerTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicTrustManagerTest.java new file mode 100644 index 000000000000..5571a17f9086 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicTrustManagerTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.cloud.spanner.omni; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class DynamicTrustManagerTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testDefaultTrustManagerWithNull() throws Exception { + DynamicTrustManager trustManager = new DynamicTrustManager((File) null); + X509Certificate[] issuers = trustManager.getAcceptedIssuers(); + assertNotNull(issuers); + assertTrue(issuers.length > 0); + } + + @Test + public void testCustomTrustManagerAndDynamicRotation() throws Exception { + SelfSignedCertificate ca1 = new SelfSignedCertificate("spanner.ca.1"); + File caFile = tempFolder.newFile("ca.crt"); + Files.write(caFile.toPath(), Files.readAllBytes(ca1.certificate().toPath())); + + DynamicTrustManager trustManager = new DynamicTrustManager(caFile); + + X509Certificate[] issuers1 = trustManager.getAcceptedIssuers(); + assertNotNull(issuers1); + assertEquals(1, issuers1.length); + assertEquals(ca1.cert().getSubjectDN(), issuers1[0].getSubjectDN()); + + // Validating ca1 cert should succeed + trustManager.checkServerTrusted(new X509Certificate[] {ca1.cert()}, "RSA"); + + SelfSignedCertificate ca2 = new SelfSignedCertificate("spanner.ca.2"); + + // Validating ca2 cert with ca1 trusted should fail + assertThrows( + CertificateException.class, + () -> trustManager.checkServerTrusted(new X509Certificate[] {ca2.cert()}, "RSA")); + + Thread.sleep(1100); + + // Rotate CA file on disk to ca2 + Files.write(caFile.toPath(), Files.readAllBytes(ca2.certificate().toPath())); + + // Now ca2 should be accepted and ca1 should be rejected + X509Certificate[] issuers2 = trustManager.getAcceptedIssuers(); + assertNotNull(issuers2); + assertEquals(1, issuers2.length); + assertEquals(ca2.cert().getSubjectDN(), issuers2[0].getSubjectDN()); + + trustManager.checkServerTrusted(new X509Certificate[] {ca2.cert()}, "RSA"); + + assertThrows( + CertificateException.class, + () -> trustManager.checkServerTrusted(new X509Certificate[] {ca1.cert()}, "RSA")); + } + + @Test + public void testMultipleCAsInFile() throws Exception { + SelfSignedCertificate ca1 = new SelfSignedCertificate("spanner.multi.ca.1"); + SelfSignedCertificate ca2 = new SelfSignedCertificate("spanner.multi.ca.2"); + + File caFile = tempFolder.newFile("multi-ca.crt"); + byte[] bundle = + (new String(Files.readAllBytes(ca1.certificate().toPath()), StandardCharsets.UTF_8) + + "\n" + + new String( + Files.readAllBytes(ca2.certificate().toPath()), StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8); + Files.write(caFile.toPath(), bundle); + + DynamicTrustManager trustManager = new DynamicTrustManager(caFile); + + X509Certificate[] issuers = trustManager.getAcceptedIssuers(); + assertNotNull(issuers); + assertEquals(2, issuers.length); + + trustManager.checkServerTrusted(new X509Certificate[] {ca1.cert()}, "RSA"); + trustManager.checkServerTrusted(new X509Certificate[] {ca2.cert()}, "RSA"); + } + + @Test + public void testCorruptRotationFallsBackToPrevious() throws Exception { + SelfSignedCertificate ca = new SelfSignedCertificate("spanner.ca.fallback"); + File caFile = tempFolder.newFile("ca-fallback.crt"); + Files.write(caFile.toPath(), Files.readAllBytes(ca.certificate().toPath())); + + DynamicTrustManager trustManager = new DynamicTrustManager(caFile); + trustManager.checkServerTrusted(new X509Certificate[] {ca.cert()}, "RSA"); + + Thread.sleep(1100); + + // Corrupt the file + Files.write(caFile.toPath(), "CORRUPT CERT DATA".getBytes(StandardCharsets.UTF_8)); + + // Trust manager should retain previous CA + trustManager.checkServerTrusted(new X509Certificate[] {ca.cert()}, "RSA"); + assertEquals(1, trustManager.getAcceptedIssuers().length); + } + + @Test + public void testNonExistentFileFailsInitialization() { + File nonExistent = new File(tempFolder.getRoot(), "missing-ca.crt"); + assertThrows(RuntimeException.class, () -> new DynamicTrustManager(nonExistent)); + } +}