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..8e139a3c000e 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; @@ -356,6 +359,9 @@ static GcpChannelPoolOptions mergeWithDefaultChannelPoolOptions( private final boolean autoTaggingEnabled; private final List autoTaggingPackages; private final int autoTaggingTracerLimit; + private final String clientCertificate; + private final String clientCertificateKey; + private final String caCertificate; enum TracingFramework { OPEN_CENSUS, @@ -941,14 +947,21 @@ protected SpannerOptions(Builder builder) { transportChannelExecutorThreadNameFormat = builder.transportChannelExecutorThreadNameFormat; channelProvider = builder.channelProvider; channelEndpointCacheFactory = builder.channelEndpointCacheFactory; - if (builder.mTLSContext != null) { + clientCertificate = builder.clientCertificate; + clientCertificateKey = builder.clientCertificateKey; + caCertificate = builder.caCertificate; + if (builder.omniSslContext != null) { + final SslContext sslContext = builder.omniSslContext; + @SuppressWarnings("rawtypes") + final ApiFunction parentConfigurator = + builder.channelConfigurator; channelConfigurator = channelBuilder -> { - if (builder.channelConfigurator != null) { - channelBuilder = builder.channelConfigurator.apply(channelBuilder); + if (parentConfigurator != null) { + channelBuilder = parentConfigurator.apply(channelBuilder); } if (channelBuilder instanceof NettyChannelBuilder) { - ((NettyChannelBuilder) channelBuilder).sslContext(builder.mTLSContext); + ((NettyChannelBuilder) channelBuilder).sslContext(sslContext); } return channelBuilder; }; @@ -1292,6 +1305,31 @@ public GoogleCredentials getDefaultSpannerOmniCredentials() { public static class Builder extends ServiceOptions.Builder { private static Builder prepareBuilder(Builder builder) { + boolean hasClientCert = !Strings.isNullOrEmpty(builder.clientCertificate); + boolean hasClientKey = !Strings.isNullOrEmpty(builder.clientCertificateKey); + boolean hasCaCert = !Strings.isNullOrEmpty(builder.caCertificate); + + if (hasClientCert || hasClientKey || hasCaCert) { + if (hasClientCert != hasClientKey) { + throw new IllegalArgumentException( + "Both clientCertificate and clientCertificateKey must be provided together"); + } + try { + SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); + if (hasClientCert) { + sslContextBuilder.keyManager( + new DynamicKeyManager( + new File(builder.clientCertificate), new File(builder.clientCertificateKey))); + } + if (hasCaCert) { + sslContextBuilder.trustManager( + new DynamicTrustManager(new File(builder.caCertificate))); + } + builder.omniSslContext = 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 +1352,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 +1437,10 @@ 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 String clientCertificate = null; + private String clientCertificateKey = null; + private String caCertificate = null; + private SslContext omniSslContext = null; private boolean usePlainText = false; private TransactionOptions defaultTransactionOptions = TransactionOptions.getDefaultInstance(); private RequestOptions.ClientContext clientContext; @@ -1517,6 +1558,9 @@ protected Builder() { this.autoTaggingEnabled = options.autoTaggingEnabled; this.autoTaggingPackages = options.autoTaggingPackages; this.autoTaggingTracerLimit = options.autoTaggingTracerLimit; + this.clientCertificate = options.clientCertificate; + this.clientCertificateKey = options.clientCertificateKey; + this.caCertificate = options.caCertificate; } @Override @@ -2240,21 +2284,33 @@ 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.checkArgument( + !Strings.isNullOrEmpty(clientCertificate), "clientCertificate cannot be null or empty"); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(clientCertificateKey), + "clientCertificateKey cannot be null or empty"); + this.clientCertificate = clientCertificate; + this.clientCertificateKey = 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.checkArgument( + !Strings.isNullOrEmpty(caCertificate), "caCertificate cannot be null or empty"); + this.caCertificate = caCertificate; return this; } @@ -3175,6 +3231,21 @@ protected boolean shouldRefreshRpc(ServiceRpc cachedRpc) { return cachedRpc == null || ((SpannerRpc) cachedRpc).isClosed(); } + @Nullable + public String getClientCertificate() { + return clientCertificate; + } + + @Nullable + public String getClientCertificateKey() { + return clientCertificateKey; + } + + @Nullable + public String getCaCertificate() { + return caCertificate; + } + @SuppressWarnings("unchecked") @Override public Builder toBuilder() { 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..90bfc6b24155 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,42 @@ public Builder setType(SpannerOptions.InstanceType instanceType) { return this; } + /** + * Sets the path to the client certificate file to use for mTLS authentication with Spanner + * Omni. + * + * @param clientCertificate The path to the client certificate file. + * @return this builder + */ + public Builder setClientCertificate(String clientCertificate) { + setConnectionPropertyValue(CLIENT_CERTIFICATE, clientCertificate); + return this; + } + + /** + * Sets the path to the client private key file to use for mTLS authentication with Spanner + * Omni. + * + * @param clientCertificateKey The path to the client private key file. + * @return this builder + */ + public Builder setClientCertificateKey(String clientCertificateKey) { + setConnectionPropertyValue(CLIENT_KEY, clientCertificateKey); + return this; + } + + /** + * Sets the path to the server root CA certificate file to use for SSL/TLS verification with + * Spanner Omni. + * + * @param caCertificate The path to the root CA certificate file. + * @return this builder + */ + public Builder setCaCertificate(String caCertificate) { + setConnectionPropertyValue(CA_CERTIFICATE, caCertificate); + return this; + } + /** * @return the {@link ConnectionOptions} */ @@ -1300,6 +1341,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..efb0d9fc8224 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicKeyManager.java @@ -0,0 +1,341 @@ +/* + * 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.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +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.util.Base64; +import java.util.Collection; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; + +/** + * 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 long DEFAULT_CHECK_INTERVAL_MS = 5000L; + + private final File certFile; + private final File keyFile; + private final long checkIntervalNs; + private final ConcurrentHashMap materials = new ConcurrentHashMap<>(); + private final AtomicLong versionCounter = new AtomicLong(); + private final ReentrantLock lock = new ReentrantLock(); + private volatile long lastCheckedNs; + + private static class CertificateFactoryHolder { + static final CertificateFactory INSTANCE; + + static { + try { + INSTANCE = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new ExceptionInInitializerError(e); + } + } + } + + private static class KeyMaterial { + final String alias; + final long certLastModified; + final long certLength; + final long keyLastModified; + final long keyLength; + final X509Certificate[] certificateChain; + final PrivateKey privateKey; + + KeyMaterial( + String alias, + long certLastModified, + long certLength, + long keyLastModified, + long keyLength, + X509Certificate[] certificateChain, + PrivateKey privateKey) { + this.alias = alias; + this.certLastModified = certLastModified; + this.certLength = certLength; + this.keyLastModified = keyLastModified; + this.keyLength = keyLength; + this.certificateChain = certificateChain; + this.privateKey = privateKey; + } + } + + private volatile KeyMaterial currentMaterial; + + /** + * Creates a {@link DynamicKeyManager} that dynamically reloads the given certificate and key + * files when modified on disk. + * + * @param certFile File containing the X.509 client certificate chain. + * @param keyFile File containing the PKCS#8 private key. + */ + public DynamicKeyManager(File certFile, File keyFile) { + this(certFile, keyFile, DEFAULT_CHECK_INTERVAL_MS); + } + + DynamicKeyManager(File certFile, File keyFile, long checkIntervalMs) { + this.certFile = Preconditions.checkNotNull(certFile, "certFile cannot be null"); + this.keyFile = Preconditions.checkNotNull(keyFile, "keyFile cannot be null"); + this.checkIntervalNs = checkIntervalMs * 1_000_000L; + try { + reloadMaterial(); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to initialize client certificate/key", e); + } + this.lastCheckedNs = System.nanoTime(); + } + + void checkAndReload() { + long now = System.nanoTime(); + if (checkIntervalNs > 0 && now - lastCheckedNs < checkIntervalNs) { + return; + } + if (!lock.tryLock()) { + return; + } + try { + long nowInLock = System.nanoTime(); + if (checkIntervalNs > 0 && nowInLock - lastCheckedNs < checkIntervalNs) { + return; + } + KeyMaterial existing = this.currentMaterial; + if (existing != null + && certFile.lastModified() == existing.certLastModified + && certFile.length() == existing.certLength + && keyFile.lastModified() == existing.keyLastModified + && keyFile.length() == existing.keyLength) { + lastCheckedNs = nowInLock; + return; + } + try { + reloadMaterial(); + } catch (Exception e) { + logger.log( + Level.WARNING, + "Failed to reload rotated client certificate/key from disk, retaining current material", + e); + } finally { + lastCheckedNs = System.nanoTime(); + } + } finally { + lock.unlock(); + } + } + + private void reloadMaterial() throws Exception { + 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); + verifyKeyMatch(chain[0].getPublicKey(), key); + + long currentVersion = versionCounter.incrementAndGet(); + String alias = "client-" + currentVersion; + KeyMaterial newMaterial = new KeyMaterial(alias, certMod, certLen, keyMod, keyLen, chain, key); + materials.put(alias, newMaterial); + this.currentMaterial = newMaterial; + + long oldestToKeep = currentVersion - 10; + materials + .keySet() + .removeIf( + keyStr -> { + if (!keyStr.startsWith("client-")) { + return false; + } + try { + long ver = Long.parseLong(keyStr.substring("client-".length())); + return ver < oldestToKeep; + } catch (NumberFormatException e) { + return false; + } + }); + } + + private static void verifyKeyMatch(PublicKey publicKey, PrivateKey privateKey) + throws GeneralSecurityException { + String algorithm = privateKey.getAlgorithm(); + String sigAlg = + "RSA".equalsIgnoreCase(algorithm) + ? "SHA256withRSA" + : ("EC".equalsIgnoreCase(algorithm) || "ECDSA".equalsIgnoreCase(algorithm)) + ? "SHA256withECDSA" + : null; + if (sigAlg != null) { + Signature sig = Signature.getInstance(sigAlg); + sig.initSign(privateKey); + sig.update(new byte[0]); + byte[] signature = sig.sign(); + sig.initVerify(publicKey); + sig.update(new byte[0]); + if (!sig.verify(signature)) { + throw new GeneralSecurityException("Private key does not match the certificate public key"); + } + } + } + + private static X509Certificate[] parseCertificates(byte[] certBytes) throws CertificateException { + CertificateFactory cf = CertificateFactoryHolder.INSTANCE; + 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 { + byte[] der; + if (keyBytes.length > 0 && keyBytes[0] == 0x30) { + der = keyBytes; + } else { + String keyStr = new String(keyBytes, StandardCharsets.UTF_8); + if (keyStr.contains("-----BEGIN RSA PRIVATE KEY-----") + || keyStr.contains("-----BEGIN EC PRIVATE KEY-----")) { + throw new IllegalArgumentException( + "PKCS#1 private keys are not supported. Please convert your key to PKCS#8 format using: " + + "openssl pkcs8 -topk8 -nocrypt -in -out "); + } + 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 rsaException) { + try { + return KeyFactory.getInstance("EC").generatePrivate(spec); + } catch (Exception ecException) { + GeneralSecurityException ex = + new GeneralSecurityException("Failed to parse private key as RSA or EC"); + ex.addSuppressed(rsaException); + ex.addSuppressed(ecException); + throw ex; + } + } + } + + 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); + return Base64.getMimeDecoder().decode(base64); + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + checkAndReload(); + KeyMaterial mat = this.currentMaterial; + return mat != null ? mat.alias : null; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + checkAndReload(); + KeyMaterial mat = this.currentMaterial; + return mat != null ? mat.alias : null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + KeyMaterial mat = alias != null ? materials.get(alias) : null; + if (mat == null) { + mat = this.currentMaterial; + } + return mat != null ? mat.certificateChain.clone() : null; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + KeyMaterial mat = alias != null ? materials.get(alias) : null; + if (mat == null) { + mat = this.currentMaterial; + } + return mat != null ? mat.privateKey : null; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + checkAndReload(); + KeyMaterial mat = this.currentMaterial; + return mat != null ? new String[] {mat.alias} : null; + } + + @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..2d5d1bd5b0fe --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/DynamicTrustManager.java @@ -0,0 +1,309 @@ +/* + * 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.concurrent.locks.ReentrantLock; +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 static final long DEFAULT_CHECK_INTERVAL_MS = 5000L; + + private final File caCertFile; + private final long checkIntervalNs; + private final ReentrantLock lock = new ReentrantLock(); + private volatile long lastCheckedNs; + + private static class CertificateFactoryHolder { + static final CertificateFactory INSTANCE; + + static { + try { + INSTANCE = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new ExceptionInInitializerError(e); + } + } + } + + 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; + + /** + * Creates a {@link DynamicTrustManager} that dynamically reloads the given root CA certificate + * file when modified on disk, or delegates to the default JVM trust store if {@code caCertFile} + * is null. + * + * @param caCertFile File containing the X.509 CA certificate(s), or null for the default JVM + * trust store. + */ + public DynamicTrustManager(@Nullable File caCertFile) { + this(caCertFile, DEFAULT_CHECK_INTERVAL_MS); + } + + DynamicTrustManager(@Nullable File caCertFile, long checkIntervalMs) { + this.caCertFile = caCertFile; + this.checkIntervalNs = checkIntervalMs * 1_000_000L; + try { + reloadMaterial(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize CA certificate", e); + } + this.lastCheckedNs = System.nanoTime(); + } + + void checkAndReload() { + if (this.caCertFile == null) { + return; + } + long now = System.nanoTime(); + if (checkIntervalNs > 0 && now - lastCheckedNs < checkIntervalNs) { + return; + } + if (!lock.tryLock()) { + return; + } + try { + long nowInLock = System.nanoTime(); + if (checkIntervalNs > 0 && nowInLock - lastCheckedNs < checkIntervalNs) { + return; + } + TrustMaterial existing = this.currentMaterial; + if (existing != null + && caCertFile.lastModified() == existing.lastModified + && caCertFile.length() == existing.length) { + lastCheckedNs = nowInLock; + return; + } + try { + reloadMaterial(); + } catch (Exception e) { + logger.log( + Level.WARNING, + "Failed to reload rotated CA certificate from disk, retaining previous material", + e); + } finally { + lastCheckedNs = System.nanoTime(); + } + } finally { + lock.unlock(); + } + } + + private void reloadMaterial() throws Exception { + 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 = CertificateFactoryHolder.INSTANCE; + 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)); + } + + 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(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkClientTrusted(chain, authType, socket); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkServerTrusted(chain, authType, socket); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkClientTrusted(chain, authType, engine); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkServerTrusted(chain, authType, engine); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + throw new CertificateException("Trust manager is not initialized"); + } + mat.delegate.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + checkAndReload(); + TrustMaterial mat = this.currentMaterial; + if (mat == null) { + return new X509Certificate[0]; + } + X509Certificate[] issuers = mat.delegate.getAcceptedIssuers(); + return issuers != null ? issuers.clone() : new X509Certificate[0]; + } +} 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..dcdc4f428068 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 @@ -71,6 +71,7 @@ import com.google.spanner.v1.SpannerGrpc; import com.google.spanner.v1.TransactionOptions.IsolationLevel; import io.grpc.MethodDescriptor; +import io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.sdk.OpenTelemetrySdk; @@ -1684,4 +1685,92 @@ public ApiCallContext configure( customOptions.toBuilder().setCallContextConfigurator(null).build(); assertNull(clearedOptions.getCallContextConfigurator()); } + + @Test + public void testUseClientCertAndTrustCertificate() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test"); + SelfSignedCertificate ca = new SelfSignedCertificate("spanner.ca"); + + try { + 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()); + assertEquals(certPath, options.getClientCertificate()); + assertEquals(keyPath, options.getClientCertificateKey()); + assertEquals(caPath, options.getCaCertificate()); + + SpannerOptions fromBuilder = options.toBuilder().build(); + assertNotNull(fromBuilder.getChannelConfigurator()); + assertEquals(certPath, fromBuilder.getClientCertificate()); + assertEquals(keyPath, fromBuilder.getClientCertificateKey()); + assertEquals(caPath, fromBuilder.getCaCertificate()); + + // Test standalone setCaCertificate + SpannerOptions caOnlyOptions = + SpannerOptions.newBuilder() + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .setHost("https://localhost:1234") + .setCaCertificate(caPath) + .build(); + + assertNotNull(caOnlyOptions.getChannelConfigurator()); + assertNull(caOnlyOptions.getClientCertificate()); + assertNull(caOnlyOptions.getClientCertificateKey()); + assertEquals(caPath, caOnlyOptions.getCaCertificate()); + + // 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()); + assertEquals(caPath, loginWithCaOptions.getCaCertificate()); + + SpannerOptions loginFromBuilder = loginWithCaOptions.toBuilder().build(); + assertTrue(loginFromBuilder.getCredentials() instanceof SpannerOmniCredentials); + assertNotNull(loginFromBuilder.getChannelConfigurator()); + assertEquals(caPath, loginFromBuilder.getCaCertificate()); + } finally { + ssc.delete(); + ca.delete(); + } + } + + @Test + public void testUseClientCertAndCaCertificateEmptyValidation() { + assertThrows( + IllegalArgumentException.class, + () -> SpannerOptions.newBuilder().useClientCert("", "/path/to/key")); + assertThrows( + IllegalArgumentException.class, + () -> SpannerOptions.newBuilder().useClientCert("/path/to/cert", "")); + assertThrows( + IllegalArgumentException.class, + () -> SpannerOptions.newBuilder().useClientCert(null, "key")); + assertThrows( + IllegalArgumentException.class, + () -> SpannerOptions.newBuilder().useClientCert("cert", null)); + assertThrows( + IllegalArgumentException.class, () -> SpannerOptions.newBuilder().setCaCertificate("")); + assertThrows( + IllegalArgumentException.class, () -> SpannerOptions.newBuilder().setCaCertificate(null)); + } } 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..e94d21dcbf55 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicKeyManagerTest.java @@ -0,0 +1,312 @@ +/* + * 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 com.google.common.truth.Truth.assertThat; +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"); + SelfSignedCertificate ssc2 = new SelfSignedCertificate("spanner.test.2"); + try { + 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, 0L); + + 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]); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc2.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc2.privateKey().toPath())); + certFile.setLastModified(System.currentTimeMillis() + 2000L); + keyFile.setLastModified(System.currentTimeMillis() + 2000L); + + 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); + } finally { + ssc1.delete(); + ssc2.delete(); + } + } + + @Test + public void testDeterministicAliasEviction() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.eviction"); + try { + File certFile = tempFolder.newFile("client-eviction.crt"); + File keyFile = tempFolder.newFile("client-eviction.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, 0L); + String firstAlias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals("client-1", firstAlias); + assertNotNull(keyManager.getCertificateChain(firstAlias)); + + // Trigger 15 rotations + for (int i = 2; i <= 15; i++) { + Thread.sleep(10); + certFile.setLastModified(System.currentTimeMillis() + i * 1000L); + keyFile.setLastModified(System.currentTimeMillis() + i * 1000L); + keyManager.checkAndReload(); + } + + String latestAlias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals("client-15", latestAlias); + assertNotNull(keyManager.getCertificateChain(latestAlias)); + + // Oldest alias "client-1" should have been evicted (oldest kept is 15 - 10 = 5) + assertNotNull(keyManager.getCertificateChain("client-15")); + assertNotNull(keyManager.getCertificateChain("client-5")); + } finally { + ssc.delete(); + } + } + + @Test + public void testFileCheckThrottling() throws Exception { + SelfSignedCertificate ssc1 = new SelfSignedCertificate("spanner.test.throttle1"); + SelfSignedCertificate ssc2 = new SelfSignedCertificate("spanner.test.throttle2"); + try { + File certFile = tempFolder.newFile("client-throttle.crt"); + File keyFile = tempFolder.newFile("client-throttle.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc1.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc1.privateKey().toPath())); + + // 60-second check interval + DynamicKeyManager keyManager = new DynamicKeyManager(certFile, keyFile, 60000L); + String alias1 = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals( + ssc1.cert().getSubjectDN(), keyManager.getCertificateChain(alias1)[0].getSubjectDN()); + + // Rotate files immediately on disk + Files.write(certFile.toPath(), Files.readAllBytes(ssc2.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc2.privateKey().toPath())); + + // Within the throttle interval, the manager should retain and return previous certificate + String aliasThrottled = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals(alias1, aliasThrottled); + assertEquals( + ssc1.cert().getSubjectDN(), keyManager.getCertificateChain(alias1)[0].getSubjectDN()); + } finally { + ssc1.delete(); + ssc2.delete(); + } + } + + @Test + public void testCorruptRotationFallsBackToPrevious() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.fallback"); + try { + 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, 0L); + String aliasBefore = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertNotNull(aliasBefore); + + // Overwrite certFile with corrupt bytes + Files.write(certFile.toPath(), "NOT A CERTIFICATE CONTENT".getBytes(StandardCharsets.UTF_8)); + certFile.setLastModified(System.currentTimeMillis() + 2000L); + + // 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)); + } finally { + ssc.delete(); + } + } + + @Test + public void testPkcs1KeyThrowsIllegalArgumentException() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.pkcs1"); + try { + File certFile = tempFolder.newFile("client-pkcs1.crt"); + File keyFile = tempFolder.newFile("client-pkcs1.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc.certificate().toPath())); + Files.write( + keyFile.toPath(), + ("-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA0Y3...\n" + + "-----END RSA PRIVATE KEY-----\n") + .getBytes(StandardCharsets.UTF_8)); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, () -> new DynamicKeyManager(certFile, keyFile)); + assertThat(exception.getMessage()).contains("PKCS#1 private keys are not supported"); + assertThat(exception.getMessage()).contains("openssl pkcs8"); + } finally { + ssc.delete(); + } + } + + @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"); + try { + 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)); + } finally { + ssc.delete(); + } + } + + @Test + public void testMismatchedCertificateAndKeyFailsInitialization() throws Exception { + SelfSignedCertificate ssc1 = new SelfSignedCertificate("spanner.test.cert1"); + SelfSignedCertificate ssc2 = new SelfSignedCertificate("spanner.test.cert2"); + try { + File certFile = tempFolder.newFile("mismatched-init.crt"); + File keyFile = tempFolder.newFile("mismatched-init.key"); + + // Pair cert from ssc1 with key from ssc2 + Files.write(certFile.toPath(), Files.readAllBytes(ssc1.certificate().toPath())); + Files.write(keyFile.toPath(), Files.readAllBytes(ssc2.privateKey().toPath())); + + RuntimeException exception = + assertThrows(RuntimeException.class, () -> new DynamicKeyManager(certFile, keyFile)); + assertThat(exception.getCause().getMessage()) + .contains("Private key does not match the certificate public key"); + } finally { + ssc1.delete(); + ssc2.delete(); + } + } + + @Test + public void testMismatchedRotationFallsBackToPrevious() throws Exception { + SelfSignedCertificate ssc1 = new SelfSignedCertificate("spanner.test.match1"); + SelfSignedCertificate ssc2 = new SelfSignedCertificate("spanner.test.match2"); + try { + File certFile = tempFolder.newFile("mismatched-rotate.crt"); + File keyFile = tempFolder.newFile("mismatched-rotate.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, 0L); + String alias1 = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals( + ssc1.cert().getSubjectDN(), keyManager.getCertificateChain(alias1)[0].getSubjectDN()); + + // Rotate only cert file (e.g., intermediate state during rotation) + Files.write(certFile.toPath(), Files.readAllBytes(ssc2.certificate().toPath())); + certFile.setLastModified(System.currentTimeMillis() + 2000L); + + // Key manager should detect mismatch and retain ssc1 credentials + String aliasAfter = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertEquals( + ssc1.cert().getSubjectDN(), keyManager.getCertificateChain(aliasAfter)[0].getSubjectDN()); + } finally { + ssc1.delete(); + ssc2.delete(); + } + } + + @Test + public void testBinaryDerKeySupported() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate("spanner.test.der"); + try { + File certFile = tempFolder.newFile("client-der.crt"); + File keyFile = tempFolder.newFile("client-der.key"); + + Files.write(certFile.toPath(), Files.readAllBytes(ssc.certificate().toPath())); + Files.write(keyFile.toPath(), ssc.key().getEncoded()); + + DynamicKeyManager keyManager = new DynamicKeyManager(certFile, keyFile); + String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null); + assertNotNull(alias); + assertNotNull(keyManager.getCertificateChain(alias)); + assertNotNull(keyManager.getPrivateKey(alias)); + } finally { + ssc.delete(); + } + } +} 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..99cfe3878071 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/omni/DynamicTrustManagerTest.java @@ -0,0 +1,173 @@ +/* + * 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"); + SelfSignedCertificate ca2 = new SelfSignedCertificate("spanner.ca.2"); + try { + File caFile = tempFolder.newFile("ca.crt"); + Files.write(caFile.toPath(), Files.readAllBytes(ca1.certificate().toPath())); + + DynamicTrustManager trustManager = new DynamicTrustManager(caFile, 0L); + + 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"); + + // Validating ca2 cert with ca1 trusted should fail + assertThrows( + CertificateException.class, + () -> trustManager.checkServerTrusted(new X509Certificate[] {ca2.cert()}, "RSA")); + + // Rotate CA file on disk to ca2 + Files.write(caFile.toPath(), Files.readAllBytes(ca2.certificate().toPath())); + caFile.setLastModified(System.currentTimeMillis() + 2000L); + + // 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")); + } finally { + ca1.delete(); + ca2.delete(); + } + } + + @Test + public void testFileCheckThrottling() throws Exception { + SelfSignedCertificate ca1 = new SelfSignedCertificate("spanner.ca.throttle1"); + SelfSignedCertificate ca2 = new SelfSignedCertificate("spanner.ca.throttle2"); + try { + File caFile = tempFolder.newFile("ca-throttle.crt"); + Files.write(caFile.toPath(), Files.readAllBytes(ca1.certificate().toPath())); + + // 60-second check interval + DynamicTrustManager trustManager = new DynamicTrustManager(caFile, 60000L); + X509Certificate[] issuers1 = trustManager.getAcceptedIssuers(); + assertEquals(1, issuers1.length); + assertEquals(ca1.cert().getSubjectDN(), issuers1[0].getSubjectDN()); + + // Rotate CA on disk immediately + Files.write(caFile.toPath(), Files.readAllBytes(ca2.certificate().toPath())); + + // Within throttle interval, trust manager should retain previous CA + assertEquals(ca1.cert().getSubjectDN(), trustManager.getAcceptedIssuers()[0].getSubjectDN()); + trustManager.checkServerTrusted(new X509Certificate[] {ca1.cert()}, "RSA"); + } finally { + ca1.delete(); + ca2.delete(); + } + } + + @Test + public void testMultipleCAsInFile() throws Exception { + SelfSignedCertificate ca1 = new SelfSignedCertificate("spanner.multi.ca.1"); + SelfSignedCertificate ca2 = new SelfSignedCertificate("spanner.multi.ca.2"); + try { + 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"); + } finally { + ca1.delete(); + ca2.delete(); + } + } + + @Test + public void testCorruptRotationFallsBackToPrevious() throws Exception { + SelfSignedCertificate ca = new SelfSignedCertificate("spanner.ca.fallback"); + try { + File caFile = tempFolder.newFile("ca-fallback.crt"); + Files.write(caFile.toPath(), Files.readAllBytes(ca.certificate().toPath())); + + DynamicTrustManager trustManager = new DynamicTrustManager(caFile, 0L); + trustManager.checkServerTrusted(new X509Certificate[] {ca.cert()}, "RSA"); + + // Corrupt the file + Files.write(caFile.toPath(), "CORRUPT CERT DATA".getBytes(StandardCharsets.UTF_8)); + caFile.setLastModified(System.currentTimeMillis() + 2000L); + + // Trust manager should retain previous CA + trustManager.checkServerTrusted(new X509Certificate[] {ca.cert()}, "RSA"); + assertEquals(1, trustManager.getAcceptedIssuers().length); + } finally { + ca.delete(); + } + } + + @Test + public void testNonExistentFileFailsInitialization() { + File nonExistent = new File(tempFolder.getRoot(), "missing-ca.crt"); + assertThrows(RuntimeException.class, () -> new DynamicTrustManager(nonExistent)); + } +}