diff --git a/java-spanner/google-cloud-spanner/pom.xml b/java-spanner/google-cloud-spanner/pom.xml
index a4f57e2e7740..2b4590be1c3b 100644
--- a/java-spanner/google-cloud-spanner/pom.xml
+++ b/java-spanner/google-cloud-spanner/pom.xml
@@ -159,14 +159,19 @@
0.6.1
com.google.protobuf:protoc:4.33.2:exe:${os.detected.classifier}
+ grpc-java
+ io.grpc:protoc-gen-grpc-java:1.64.0:exe:${os.detected.classifier}
${project.basedir}/../proto-google-cloud-spanner-v1/src/main/proto
+ ${project.basedir}/../google-cloud-spanner/src/main/proto
- test-compile
+ compile
+ compile
+ compile-custom
test-compile
@@ -544,6 +549,16 @@
javax.annotation
javax.annotation-api
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.78
+
+
+ com.google.crypto.tink
+ tink
+ 1.13.0
+
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 2995730664e2..257dc1f263f5 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
@@ -91,6 +91,7 @@
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Duration;
@@ -1817,6 +1818,84 @@ public Builder setExperimentalHost(String host) {
return this;
}
+
+ /**
+ * Authenticates to Spanner Omni using the provided username and password file, and configures
+ * the resulting token for use in subsequent Spanner API calls. The endpoint must be set on the
+ * builder before calling this method.
+ *
+ * @param username The username for login.
+ * @param passwordFile The path to a file containing the password.
+ * @return this builder
+ */
+ public Builder login(String username, String passwordFile) {
+ return login(username, passwordFile, true);
+ }
+
+ /**
+ * Authenticates to Spanner Omni using the provided username and password file, and configures
+ * the resulting token for use in subsequent Spanner API calls. The endpoint must be set on the
+ * builder before calling this method.
+ *
+ * @param username The username for login.
+ * @param passwordFile The path to a file containing the password.
+ * @param backgroundRefresh Whether to proactively refresh the token in a background thread before it expires. If false, GAX still triggers a synchronous inline refresh upon UNAUTHENTICATED error.
+ * @return this builder
+ */
+ public Builder login(String username, String passwordFile, boolean backgroundRefresh) {
+ try {
+ byte[] rawBytes = Files.readAllBytes(Paths.get(passwordFile));
+ int len = rawBytes.length;
+ while (len > 0 && (rawBytes[len - 1] == '\n' || rawBytes[len - 1] == '\r')) {
+ len--;
+ }
+ byte[] passwordBytes = java.util.Arrays.copyOf(rawBytes, len);
+ return loginWithPasswordBytes(username, passwordBytes, backgroundRefresh);
+ } catch (IOException e) {
+ throw SpannerExceptionFactory.newSpannerException(
+ ErrorCode.NOT_FOUND, "Could not read password file: " + passwordFile, e);
+ }
+ }
+
+ /**
+ * Authenticates to Spanner Omni using the provided username and password, and configures the
+ * resulting token for use in subsequent Spanner API calls. The endpoint must be set on the
+ * builder before calling this method.
+ *
+ * @param username The username for login.
+ * @param password The password for login.
+ * @return this builder
+ */
+ public Builder loginWithPassword(String username, String password) {
+ return loginWithPassword(username, password, true);
+ }
+
+ /**
+ * Authenticates to Spanner Omni using the provided username and password, and configures the
+ * resulting token for use in subsequent Spanner API calls. The endpoint must be set on the
+ * builder before calling this method.
+ *
+ * @param username The username for login.
+ * @param password The password for login.
+ * @param backgroundRefresh Whether to proactively refresh the token in a background thread before it expires. If false, GAX still triggers a synchronous inline refresh upon UNAUTHENTICATED error.
+ * @return this builder
+ */
+ public Builder loginWithPassword(String username, String password, boolean backgroundRefresh) {
+ return loginWithPasswordBytes(username, password.getBytes(StandardCharsets.UTF_8), backgroundRefresh);
+ }
+
+ private Builder loginWithPasswordBytes(String username, byte[] password, boolean backgroundRefresh) {
+ if (this.experimentalHost == null) {
+ throw new IllegalStateException("Endpoint must be set before calling login.");
+ }
+ String target = this.experimentalHost.replaceFirst("^https?://", "");
+ com.google.crypto.tink.util.SecretBytes secretBytes = com.google.crypto.tink.util.SecretBytes.copyFrom(
+ password, com.google.crypto.tink.InsecureSecretKeyAccess.get());
+ java.util.Arrays.fill(password, (byte) 0);
+ super.setCredentials(new com.google.cloud.spanner.omni.SpannerOmniCredentials(username, secretBytes, target, backgroundRefresh));
+ return this;
+ }
+
/** Enables gRPC-GCP extension with the default settings. This option is enabled by default. */
public Builder enableGrpcGcpExtension() {
return this.enableGrpcGcpExtension(null);
diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/LoginClient.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/LoginClient.java
new file mode 100644
index 000000000000..3d8396ba19a9
--- /dev/null
+++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/LoginClient.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2024 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.cloud.spanner.SpannerException;
+import com.google.cloud.spanner.SpannerExceptionFactory;
+import com.google.common.base.Preconditions;
+import com.google.cloud.spanner.omni.opaque.OpaqueUtil;
+import com.google.protobuf.ByteString;
+import google.spanner.omni.v1.AccessToken;
+import google.spanner.omni.v1.FinalOpaqueLoginRequest;
+import google.spanner.omni.v1.InitialOpaqueLoginRequest;
+import google.spanner.omni.v1.InitialOpaqueLoginResponse;
+import google.spanner.omni.v1.LoginRequest;
+import google.spanner.omni.v1.LoginResponse;
+import google.spanner.omni.v1.LoginServiceGrpc;
+import google.spanner.omni.v1.OpaqueLoginRequest;
+import io.grpc.ManagedChannel;
+import io.grpc.stub.StreamObserver;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import com.google.crypto.tink.util.SecretBytes;
+import com.google.crypto.tink.InsecureSecretKeyAccess;
+
+/**
+ * Client for {@link google.spanner.omni.v1.LoginServiceGrpc}. This class is used to
+ * authenticate to Spanner Omni using username/password.
+ */
+public class LoginClient {
+ private static final java.security.SecureRandom SECURE_RANDOM = new java.security.SecureRandom();
+
+ private final LoginServiceGrpc.LoginServiceStub stub;
+
+ public LoginClient(ManagedChannel channel) {
+ this.stub = LoginServiceGrpc.newStub(channel).withDeadlineAfter(60, java.util.concurrent.TimeUnit.SECONDS);
+ }
+
+ /**
+ * Logs in to Spanner Omni using OPAQUE protocol.
+ *
+ * @param username The username to login with.
+ * @param password The password to login with.
+ * @return The access token.
+ * @throws SpannerException if login fails.
+ */
+ public AccessToken login(String username, SecretBytes password) throws SpannerException {
+ Preconditions.checkNotNull(username);
+ Preconditions.checkNotNull(password);
+ byte[] passwordBytes = null;
+ try {
+ passwordBytes = password.toByteArray(InsecureSecretKeyAccess.get());
+ byte[] randomNonce = OpaqueUtil.nonce();
+ byte[][] keyPair = OpaqueUtil.generateKeyPair(OpaqueUtil.concat(randomNonce, OpaqueUtil.DIFFIE_HELLMAN_KEY_INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ byte[] clientPrivateKeyshare = keyPair[0];
+ byte[] clientPublicKeyshare = keyPair[1];
+ byte[] clientNonce = OpaqueUtil.nonce();
+ byte[] blind = new byte[32];
+ SECURE_RANDOM.nextBytes(blind);
+
+ byte[] blindedMessage = OpaqueUtil.blind(passwordBytes, blind);
+
+ LoginRequest initialRequest =
+ LoginRequest.newBuilder()
+ .setUsername(username)
+ .setOpaqueRequest(
+ OpaqueLoginRequest.newBuilder()
+ .setInitialRequest(
+ InitialOpaqueLoginRequest.newBuilder()
+ .setBlindedMessage(ByteString.copyFrom(blindedMessage))
+ .setClientNonce(ByteString.copyFrom(clientNonce))
+ .setClientPublicKeyshare(ByteString.copyFrom(clientPublicKeyshare))))
+ .build();
+
+ LoginStreamIOCall call = new LoginStreamIOCall(stub);
+ call.send(initialRequest);
+ LoginResponse initialResponse = call.getResponse();
+
+ InitialOpaqueLoginResponse initialOpaqueResponse =
+ initialResponse.getOpaqueResponse().getInitialResponse();
+
+ byte[] clientMac =
+ generateClientMac(
+ username,
+ passwordBytes,
+ blind,
+ clientNonce,
+ clientPublicKeyshare,
+ clientPrivateKeyshare,
+ initialOpaqueResponse);
+
+ LoginRequest finalRequest =
+ LoginRequest.newBuilder()
+ .setUsername(username)
+ .setOpaqueRequest(
+ OpaqueLoginRequest.newBuilder()
+ .setFinalRequest(
+ FinalOpaqueLoginRequest.newBuilder()
+ .setClientMac(ByteString.copyFrom(clientMac))))
+ .build();
+
+ call.send(finalRequest);
+ call.halfClose();
+ LoginResponse finalResponse = call.getResponse();
+ return finalResponse.getAccessToken();
+ } catch (GeneralSecurityException | IOException | InterruptedException e) {
+ throw SpannerExceptionFactory.newSpannerException(e);
+ } finally {
+ if (passwordBytes != null) {
+ java.util.Arrays.fill(passwordBytes, (byte) 0);
+ }
+ }
+ }
+
+ private byte[] generateClientMac(
+ String username,
+ byte[] password,
+ byte[] blind,
+ byte[] clientNonce,
+ byte[] clientPublicKeyshare,
+ byte[] clientPrivateKeyshare,
+ InitialOpaqueLoginResponse initialOpaqueResponse)
+ throws GeneralSecurityException, IOException {
+ byte[] oprf =
+ OpaqueUtil.finalize(blind, initialOpaqueResponse.getEvaluatedMessage().toByteArray());
+ byte[] stretchedOprf = OpaqueUtil.stretch(oprf);
+ byte[] randomizedPassword = OpaqueUtil.extract(OpaqueUtil.concat(oprf, stretchedOprf));
+ byte[] maskingKey =
+ OpaqueUtil.expand(randomizedPassword, OpaqueUtil.MASKING_KEY_INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8), 32);
+ byte[] credentialResponsePad =
+ OpaqueUtil.expand(
+ maskingKey,
+ OpaqueUtil.concat(
+ initialOpaqueResponse.getMaskingNonce().toByteArray(),
+ "CredentialResponsePad".getBytes(java.nio.charset.StandardCharsets.UTF_8)),
+ 16 + 33 + 16);
+ byte[] serializedEnvelope =
+ OpaqueUtil.xorBytes(
+ initialOpaqueResponse.getMaskedResponse().toByteArray(), credentialResponsePad);
+ ByteString envelope = ByteString.copyFrom(serializedEnvelope);
+ ByteString serverPublicKey = envelope.substring(0, 33);
+ ByteString envelopeNonce = envelope.substring(33, 33 + 16);
+ ByteString authTag = envelope.substring(33 + 16, 33 + 16 + 16);
+
+ byte[] authKey =
+ OpaqueUtil.expand(
+ randomizedPassword,
+ OpaqueUtil.concat(envelopeNonce.toByteArray(), OpaqueUtil.AUTH_KEY_INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
+ 32);
+ byte[] seed =
+ OpaqueUtil.expand(
+ randomizedPassword,
+ OpaqueUtil.concat(envelopeNonce.toByteArray(), OpaqueUtil.PRIVATE_KEY_INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
+ 32);
+ byte[][] clientKeyPair = OpaqueUtil.generateKeyPair(OpaqueUtil.concat(seed, OpaqueUtil.DIFFIE_HELLMAN_KEY_INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ byte[] clientPrivateKey = clientKeyPair[0];
+ byte[] clientPublicKey = clientKeyPair[1];
+
+
+ byte[] expectedTag =
+ OpaqueUtil.mac(
+ authKey,
+ OpaqueUtil.concat(
+ envelopeNonce.toByteArray(), serverPublicKey.toByteArray(), username.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ if (!ByteString.copyFrom(expectedTag).equals(authTag)) {
+ throw new GeneralSecurityException("Auth tag mismatch");
+ }
+
+ byte[] dh1 =
+ OpaqueUtil.diffieHellman(
+ clientPrivateKeyshare, initialOpaqueResponse.getServerPublicKeyshare().toByteArray());
+ byte[] dh2 = OpaqueUtil.diffieHellman(clientPrivateKeyshare, serverPublicKey.toByteArray());
+ byte[] dh3 =
+ OpaqueUtil.diffieHellman(
+ clientPrivateKey, initialOpaqueResponse.getServerPublicKeyshare().toByteArray());
+
+ byte[] inputKeyMaterial = OpaqueUtil.concat(dh1, dh2, dh3);
+
+ byte[] preamble =
+ OpaqueUtil.concat(
+ "OPAQUEv1-".getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ username.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ clientNonce,
+ clientPublicKeyshare,
+ serverPublicKey.toByteArray(),
+ initialOpaqueResponse.getEvaluatedMessage().toByteArray(),
+ initialOpaqueResponse.getServerNonce().toByteArray(),
+ initialOpaqueResponse.getServerPublicKeyshare().toByteArray());
+ byte[] prk = OpaqueUtil.extract(inputKeyMaterial);
+ byte[] preambleHash = OpaqueUtil.sha256(preamble);
+ byte[] handshakeSecret =
+ OpaqueUtil.expand(prk, OpaqueUtil.concat("OPAQUE-HandshakeSecret".getBytes(java.nio.charset.StandardCharsets.UTF_8), preambleHash), 32);
+ byte[] km2 = OpaqueUtil.expand(handshakeSecret, "OPAQUE-ServerMAC".getBytes(java.nio.charset.StandardCharsets.UTF_8), 32);
+ byte[] km3 = OpaqueUtil.expand(handshakeSecret, "OPAQUE-ClientMAC".getBytes(java.nio.charset.StandardCharsets.UTF_8), 32);
+
+ byte[] expectedServerMac = OpaqueUtil.mac(km2, OpaqueUtil.sha256(preamble));
+ if (!ByteString.copyFrom(expectedServerMac).equals(initialOpaqueResponse.getServerMac())) {
+ throw new GeneralSecurityException("Server MAC mismatch");
+ }
+ return OpaqueUtil.mac(km3, OpaqueUtil.sha256(OpaqueUtil.concat(preamble, expectedServerMac)));
+ }
+
+ static class LoginStreamIOCall {
+ private final LoginServiceGrpc.LoginServiceStub stub;
+ private final java.util.concurrent.BlockingQueue responseQueue = new java.util.concurrent.LinkedBlockingQueue<>();
+ private StreamObserver requestObserver;
+ private Throwable error;
+ private boolean completed = false;
+
+ LoginStreamIOCall(LoginServiceGrpc.LoginServiceStub stub) {
+ this.stub = stub;
+ requestObserver =
+ stub.login(
+ new StreamObserver() {
+ @Override
+ public void onNext(LoginResponse value) {
+ responseQueue.add(value);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ error = t;
+ // Add a dummy response to unblock getResponse if it's waiting
+ responseQueue.add(LoginResponse.getDefaultInstance());
+ }
+
+ @Override
+ public void onCompleted() {
+ completed = true;
+ // Add a dummy response to unblock getResponse if it's waiting
+ responseQueue.add(LoginResponse.getDefaultInstance());
+ }
+ });
+ }
+
+ void send(LoginRequest request) {
+ requestObserver.onNext(request);
+ }
+
+ LoginResponse getResponse() throws InterruptedException {
+ LoginResponse response = responseQueue.take();
+ if (error != null) {
+ throw SpannerExceptionFactory.newSpannerException(error);
+ }
+ if (response == LoginResponse.getDefaultInstance() && completed) {
+ return null;
+ }
+ return response;
+ }
+
+ void halfClose() {
+ requestObserver.onCompleted();
+ }
+ }
+}
+
diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/SpannerOmniCredentials.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/SpannerOmniCredentials.java
new file mode 100644
index 000000000000..4a7daada507c
--- /dev/null
+++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/SpannerOmniCredentials.java
@@ -0,0 +1,141 @@
+/*
+ * 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.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.AccessToken;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import java.io.IOException;
+import java.util.Date;
+import java.util.Base64;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import com.google.crypto.tink.util.SecretBytes;
+
+/**
+ * Credentials implementation for Spanner Omni. Uses the OPAQUE protocol to
+ * authenticate and fetches short-lived access tokens. Supports optional
+ * background auto-refreshing before token expiry.
+ */
+public class SpannerOmniCredentials extends GoogleCredentials {
+ private static final Logger logger = Logger.getLogger(SpannerOmniCredentials.class.getName());
+
+ private static final ScheduledExecutorService SHARED_EXECUTOR = Executors.newScheduledThreadPool(1, r -> {
+ Thread t = new Thread(r, "spanner-omni-refresh");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private final String username;
+ private final SecretBytes password;
+ private final String target;
+ private final boolean backgroundRefresh;
+ private final ManagedChannel loginChannel;
+
+ private ScheduledFuture> refreshTask;
+
+ public SpannerOmniCredentials(String username, SecretBytes password, String target) {
+ this(username, password, target, true);
+ }
+
+ public SpannerOmniCredentials(String username, SecretBytes password, String target, boolean backgroundRefresh) {
+ this.username = username;
+ this.password = password;
+ this.target = target;
+ this.backgroundRefresh = backgroundRefresh;
+ this.loginChannel = ManagedChannelBuilder.forTarget(target).build();
+ }
+
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ try {
+ LoginClient loginClient = new LoginClient(this.loginChannel);
+ google.spanner.omni.v1.AccessToken protoToken = loginClient.login(username, password);
+ String tokenValue = Base64.getEncoder().encodeToString(protoToken.toByteArray());
+
+ long createTimeMillis = protoToken.getCreationTime().getSeconds() * 1000 +
+ protoToken.getCreationTime().getNanos() / 1000000;
+ long expireTimeMillis = protoToken.getExpirationTime().getSeconds() * 1000 +
+ protoToken.getExpirationTime().getNanos() / 1000000;
+
+ long tokenLifetimeMillis = expireTimeMillis - createTimeMillis;
+ if (tokenLifetimeMillis <= 0) {
+ tokenLifetimeMillis = TimeUnit.MINUTES.toMillis(60);
+ }
+
+ AccessToken newAccessToken = new AccessToken(tokenValue, new Date(System.currentTimeMillis() + tokenLifetimeMillis));
+
+ if (backgroundRefresh && !SHARED_EXECUTOR.isShutdown()) {
+ scheduleRefresh(tokenLifetimeMillis);
+ }
+
+ return newAccessToken;
+ } catch (Exception e) {
+ logger.log(Level.SEVERE, "Failed to login to Spanner Omni. Username: " + username + ", Target: " + target, e);
+ throw new IOException("Failed to login to Spanner Omni", e);
+ }
+ }
+
+ private void scheduleRefresh(long tokenLifetimeMillis) {
+ if (refreshTask != null && !refreshTask.isDone()) {
+ refreshTask.cancel(false);
+ }
+
+ long delayMillis;
+ if (tokenLifetimeMillis <= TimeUnit.MINUTES.toMillis(5)) {
+ // For very short-lived tokens (e.g. 15s), refresh at half their lifetime
+ delayMillis = tokenLifetimeMillis / 2;
+ } else {
+ // For long-lived tokens, refresh 5 minutes before expiry
+ delayMillis = tokenLifetimeMillis - TimeUnit.MINUTES.toMillis(5);
+ }
+
+ if (delayMillis < 0) {
+ delayMillis = 0;
+ }
+
+ java.lang.ref.WeakReference weakThis = new java.lang.ref.WeakReference<>(this);
+
+ Runnable refreshAction = new Runnable() {
+ @Override
+ public void run() {
+ SpannerOmniCredentials creds = weakThis.get();
+ if (creds == null) {
+ // The credentials instance was garbage collected. Stop the background refresh loop.
+ return;
+ }
+ try {
+ creds.refresh();
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Failed to auto-refresh Spanner Omni credentials", e);
+ // Retry in a short interval on failure
+ long retryDelay = Math.min(TimeUnit.SECONDS.toMillis(5), tokenLifetimeMillis / 4);
+ if (retryDelay <= 0) retryDelay = 1000;
+ creds.refreshTask = SHARED_EXECUTOR.schedule(this, retryDelay, TimeUnit.MILLISECONDS);
+ }
+ }
+ };
+
+ refreshTask = SHARED_EXECUTOR.schedule(refreshAction, delayMillis, TimeUnit.MILLISECONDS);
+ }
+}
diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/opaque/OpaqueUtil.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/opaque/OpaqueUtil.java
new file mode 100644
index 000000000000..460727403cce
--- /dev/null
+++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/omni/opaque/OpaqueUtil.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright 2024 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.opaque;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import com.google.crypto.tink.subtle.Hkdf;
+import org.bouncycastle.asn1.x9.X9ECParameters;
+import org.bouncycastle.crypto.ec.CustomNamedCurves;
+import org.bouncycastle.math.ec.ECCurve;
+import org.bouncycastle.math.ec.ECPoint;
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
+import org.bouncycastle.crypto.params.Argon2Parameters;
+
+public class OpaqueUtil {
+
+ public static final String LOGIN_DOMAIN_SEPARATION_TAG = "Spanner-Omni-Login";
+ public static final String CURVE_NAME = "secp256r1";
+ public static final String AUTH_KEY_INFO = "AuthKey";
+ public static final String EXPORT_KEY_INFO = "ExportKey";
+ public static final String PRIVATE_KEY_INFO = "PrivateKey";
+ public static final String MASKING_KEY_INFO = "MaskingKey";
+ public static final String DIFFIE_HELLMAN_KEY_INFO = "OPAQUE-DeriveDiffieHellmanKeyPair";
+ public static final String HMAC_SHA256 = "HmacSHA256";
+
+ private static final int NONCE_LENGTH = 16;
+ private static final int MAC_TAG_LENGTH = 16;
+ private static final int EXTRACT_OUTPUT_LENGTH = 32;
+ private static final int STRETCH_OUTPUT_LENGTH = 32;
+
+ // Argon2ID parameters.
+ private static final int ARGON2_ITERATION_COUNT = 3;
+ private static final int ARGON2_MEMORY_LIMIT = 64 * 1024;
+ private static final int ARGON2_THREADS = 4;
+ private static final int ARGON2_SALT_LENGTH = 32;
+
+ private static final SecureRandom random = new SecureRandom();
+
+ public static byte[] nonce() {
+ byte[] nonce = new byte[NONCE_LENGTH];
+ random.nextBytes(nonce);
+ return nonce;
+ }
+
+ public static byte[] hmacSha256(byte[] key, byte[] message) throws GeneralSecurityException {
+ Mac mac = Mac.getInstance(HMAC_SHA256);
+ mac.init(new SecretKeySpec(key, HMAC_SHA256));
+ return mac.doFinal(message);
+ }
+
+ public static byte[] sha256(byte[] message) throws NoSuchAlgorithmException {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return digest.digest(message);
+ }
+
+ private static final BigInteger p = new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951");
+ private static final BigInteger A = p.subtract(new BigInteger("3"));
+ private static final BigInteger B = new BigInteger("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b", 16);
+ private static final BigInteger Z = p.subtract(BigInteger.valueOf(10));
+
+ private static byte[] expandMessageXmd(byte[] msg, byte[] DST, int lenInBytes) throws GeneralSecurityException {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ int bInBytes = 32;
+ int ell = (lenInBytes + bInBytes - 1) / bInBytes;
+
+ byte[] dstPrime = new byte[DST.length + 1];
+ System.arraycopy(DST, 0, dstPrime, 0, DST.length);
+ dstPrime[DST.length] = (byte) DST.length;
+
+ byte[] zPad = new byte[64];
+ byte[] libStr = new byte[] { (byte) (lenInBytes >> 8), (byte) (lenInBytes & 0xFF) };
+
+ md.update(zPad);
+ md.update(msg);
+ md.update(libStr);
+ md.update((byte) 0);
+ md.update(dstPrime);
+ byte[] b0 = md.digest();
+
+ byte[] bOut = new byte[ell * bInBytes];
+
+ md.update(b0);
+ md.update((byte) 1);
+ md.update(dstPrime);
+ byte[] b1 = md.digest();
+ System.arraycopy(b1, 0, bOut, 0, bInBytes);
+
+ byte[] bi = b1;
+ for (int i = 2; i <= ell; i++) {
+ byte[] bXor = new byte[bInBytes];
+ for (int j = 0; j < bInBytes; j++) {
+ bXor[j] = (byte) (b0[j] ^ bi[j]);
+ }
+ md.update(bXor);
+ md.update((byte) i);
+ md.update(dstPrime);
+ bi = md.digest();
+ System.arraycopy(bi, 0, bOut, (i - 1) * bInBytes, bInBytes);
+ }
+
+ byte[] res = new byte[lenInBytes];
+ System.arraycopy(bOut, 0, res, 0, lenInBytes);
+ return res;
+ } catch (Exception e) {
+ throw new GeneralSecurityException("Failed to expand message", e);
+ }
+ }
+
+ private static int sgn0(BigInteger x) {
+ return x.testBit(0) ? 1 : 0;
+ }
+
+ private static ECPoint mapToCurveSSWU(BigInteger u, ECCurve curve) {
+ BigInteger u2 = u.multiply(u).mod(p);
+ BigInteger z_u2 = Z.multiply(u2).mod(p);
+ BigInteger z2_u4 = z_u2.multiply(z_u2).mod(p);
+ BigInteger den = z2_u4.add(z_u2).mod(p);
+
+ BigInteger tv1;
+ if (den.equals(BigInteger.ZERO)) {
+ tv1 = BigInteger.ZERO;
+ } else {
+ tv1 = den.modInverse(p);
+ }
+
+ BigInteger x1;
+ if (tv1.equals(BigInteger.ZERO)) {
+ BigInteger za = Z.multiply(A).mod(p);
+ x1 = B.multiply(za.modInverse(p)).mod(p);
+ } else {
+ BigInteger negB_div_A = B.negate().multiply(A.modInverse(p)).mod(p);
+ BigInteger one_plus_tv1 = BigInteger.ONE.add(tv1).mod(p);
+ x1 = negB_div_A.multiply(one_plus_tv1).mod(p);
+ }
+
+ BigInteger gx1 = x1.pow(3).add(A.multiply(x1)).add(B).mod(p);
+ BigInteger x2 = z_u2.multiply(x1).mod(p);
+ BigInteger gx2 = x2.pow(3).add(A.multiply(x2)).add(B).mod(p);
+
+ BigInteger c1 = p.add(BigInteger.ONE).divide(BigInteger.valueOf(4));
+ BigInteger root1 = gx1.modPow(c1, p);
+ boolean isSquare = root1.multiply(root1).mod(p).equals(gx1);
+
+ BigInteger x, y;
+ if (isSquare) {
+ x = x1;
+ y = root1;
+ } else {
+ x = x2;
+ y = gx2.modPow(c1, p);
+ }
+
+ if (sgn0(u) != sgn0(y)) {
+ y = y.negate().mod(p);
+ }
+
+ return curve.createPoint(x, y);
+ }
+
+ public static byte[] getHashToCurve(byte[] message, byte[] domain) throws GeneralSecurityException {
+ byte[] uniformBytes = expandMessageXmd(message, domain, 96);
+ byte[] u0Bytes = new byte[48];
+ byte[] u1Bytes = new byte[48];
+ System.arraycopy(uniformBytes, 0, u0Bytes, 0, 48);
+ System.arraycopy(uniformBytes, 48, u1Bytes, 0, 48);
+
+ BigInteger u0 = new BigInteger(1, u0Bytes).mod(p);
+ BigInteger u1 = new BigInteger(1, u1Bytes).mod(p);
+
+ X9ECParameters params = CustomNamedCurves.getByName(CURVE_NAME);
+ ECCurve curve = params.getCurve();
+
+ ECPoint q0 = mapToCurveSSWU(u0, curve);
+ ECPoint q1 = mapToCurveSSWU(u1, curve);
+
+ ECPoint r = q0.add(q1).normalize();
+ return r.getEncoded(true);
+ }
+
+ public static byte[] blind(byte[] password, byte[] blindScalar) throws GeneralSecurityException {
+ byte[] hashedPoint = getHashToCurve(password, LOGIN_DOMAIN_SEPARATION_TAG.getBytes(UTF_8));
+
+ X9ECParameters params = CustomNamedCurves.getByName(CURVE_NAME);
+ ECCurve curve = params.getCurve();
+
+ ECPoint point = curve.decodePoint(hashedPoint);
+ BigInteger scalar = new BigInteger(1, blindScalar);
+
+ return point.multiply(scalar).getEncoded(true);
+ }
+
+ public static byte[] expand(byte[] keyMaterial, byte[] info, int size)
+ throws GeneralSecurityException {
+ return Hkdf.computeHkdf(HMAC_SHA256, keyMaterial, new byte[0], info, size);
+ }
+
+ public static byte[] stretch(byte[] input) throws GeneralSecurityException {
+ byte[] salt = expand(input, "Stretch".getBytes(UTF_8), ARGON2_SALT_LENGTH);
+ Argon2Parameters params =
+ new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withSalt(salt)
+ .withParallelism(ARGON2_THREADS)
+ .withMemoryAsKB(ARGON2_MEMORY_LIMIT)
+ .withIterations(ARGON2_ITERATION_COUNT)
+ .build();
+ Argon2BytesGenerator generator = new Argon2BytesGenerator();
+ generator.init(params);
+ byte[] result = new byte[STRETCH_OUTPUT_LENGTH];
+ generator.generateBytes(input, result);
+ return result;
+ }
+
+ public static byte[] extract(byte[] inputKeyMaterial) throws GeneralSecurityException {
+ return expand(inputKeyMaterial, "Extract".getBytes(UTF_8), EXTRACT_OUTPUT_LENGTH);
+ }
+
+ public static byte[] xorBytes(byte[] a, byte[] b) {
+ if (a.length != b.length) {
+ throw new IllegalArgumentException("Byte arrays must have same length");
+ }
+ byte[] result = new byte[a.length];
+ for (int i = 0; i < a.length; i++) {
+ result[i] = (byte) (a[i] ^ b[i]);
+ }
+ return result;
+ }
+
+ public static byte[] concat(byte[]... arrays) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (byte[] array : arrays) {
+ out.write(array);
+ }
+ return out.toByteArray();
+ }
+
+ public static byte[] mac(byte[] key, byte[] data) throws GeneralSecurityException {
+ byte[] result = hmacSha256(key, data);
+ byte[] truncated = new byte[MAC_TAG_LENGTH];
+ System.arraycopy(result, 0, truncated, 0, MAC_TAG_LENGTH);
+ return truncated;
+ }
+
+ public static byte[] finalize(byte[] blind, byte[] evaluatedMessage)
+ throws GeneralSecurityException {
+ BigInteger blindBigInt = new BigInteger(1, blind);
+ X9ECParameters params = CustomNamedCurves.getByName(CURVE_NAME);
+ BigInteger order = params.getN();
+ BigInteger inverseBlind = blindBigInt.modInverse(order);
+
+ ECCurve curve = params.getCurve();
+ ECPoint evaluatedPoint = curve.decodePoint(evaluatedMessage);
+
+ return evaluatedPoint.multiply(inverseBlind).getEncoded(true);
+ }
+
+ public static byte[] diffieHellman(byte[] privateKey, byte[] peerPublicKey)
+ throws GeneralSecurityException {
+ X9ECParameters params = CustomNamedCurves.getByName(CURVE_NAME);
+ ECCurve curve = params.getCurve();
+ ECPoint peerPublicPoint = curve.decodePoint(peerPublicKey);
+ BigInteger priv = new BigInteger(1, privateKey);
+ return peerPublicPoint.multiply(priv).getEncoded(true);
+ }
+
+ public static byte[] randomOracleSha256(byte[] x, BigInteger max) throws GeneralSecurityException {
+ int hashOutputLength = 256;
+ int outputBitLength = max.bitLength() + hashOutputLength;
+ int iterCount = (int) Math.ceil((double) outputBitLength / hashOutputLength);
+ if (iterCount * hashOutputLength > 130048) {
+ throw new GeneralSecurityException("the domain bit length must not be greater than 130048");
+ }
+ int excessBitCount = (iterCount * hashOutputLength) - outputBitLength;
+ BigInteger hashOutput = BigInteger.ZERO;
+
+ for (int i = 1; i <= iterCount; i++) {
+ hashOutput = hashOutput.shiftLeft(hashOutputLength);
+ byte[] iBytes = BigInteger.valueOf(i).toByteArray();
+ // Remove leading zero byte if present from two's complement representation
+ if (iBytes.length > 1 && iBytes[0] == 0) {
+ byte[] tmp = new byte[iBytes.length - 1];
+ System.arraycopy(iBytes, 1, tmp, 0, tmp.length);
+ iBytes = tmp;
+ }
+
+ byte[] bignumBytes;
+ try {
+ bignumBytes = concat(iBytes, x);
+ } catch (IOException e) {
+ throw new GeneralSecurityException(e);
+ }
+ byte[] hashedString = sha256(bignumBytes);
+
+ // Ensure hashedString is treated as a positive integer (prepend 0x00)
+ byte[] positiveHashedString = new byte[hashedString.length + 1];
+ System.arraycopy(hashedString, 0, positiveHashedString, 1, hashedString.length);
+ BigInteger newBigNum = new BigInteger(positiveHashedString);
+
+ hashOutput = hashOutput.add(newBigNum);
+ }
+
+ hashOutput = hashOutput.shiftRight(excessBitCount);
+ hashOutput = hashOutput.mod(max);
+
+ byte[] scalarBytes = new byte[hashOutputLength / 8];
+ byte[] hashOutputBytes = hashOutput.toByteArray();
+
+ // Copy into 32 byte array
+ if (hashOutputBytes.length <= scalarBytes.length) {
+ System.arraycopy(hashOutputBytes, 0, scalarBytes, scalarBytes.length - hashOutputBytes.length, hashOutputBytes.length);
+ } else {
+ // If hashOutputBytes is 33 bytes due to sign bit
+ System.arraycopy(hashOutputBytes, hashOutputBytes.length - scalarBytes.length, scalarBytes, 0, scalarBytes.length);
+ }
+ return scalarBytes;
+ }
+
+ public static byte[][] generateKeyPair(byte[] deriveInput) throws GeneralSecurityException {
+ X9ECParameters params = CustomNamedCurves.getByName(CURVE_NAME);
+ BigInteger order = params.getN();
+ byte[] privateKeyBytes = randomOracleSha256(deriveInput, order);
+ BigInteger privateKey = new BigInteger(1, privateKeyBytes);
+
+ if (privateKey.equals(BigInteger.ZERO)) {
+ privateKey = BigInteger.ONE;
+ privateKeyBytes = new byte[32];
+ privateKeyBytes[31] = 1;
+ }
+ ECPoint publicKey = params.getG().multiply(privateKey);
+ return new byte[][] {privateKeyBytes, publicKey.getEncoded(true)};
+ }
+}
\ No newline at end of file
diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/ExperimentalHostHelper.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/ExperimentalHostHelper.java
index f6387535e4d8..1fd182bbdefa 100644
--- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/ExperimentalHostHelper.java
+++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/ExperimentalHostHelper.java
@@ -25,6 +25,8 @@ public class ExperimentalHostHelper {
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 USERNAME = "spanner.username";
+ private static final String PASSWORD_FILE = "spanner.password_file";
/**
* Checks whether the emulator is being used. This is done by checking if the
@@ -55,6 +57,11 @@ public static void setExperimentalHostSpannerOptions(SpannerOptions.Builder buil
boolean usePlainText = Boolean.getBoolean(USE_PLAIN_TEXT);
builder.setExperimentalHost(experimentalHost);
builder.setBuiltInMetricsEnabled(false);
+ String username = System.getProperty(USERNAME,"");
+ String passwordFile = System.getProperty(PASSWORD_FILE,"");
+ if(!Strings.isNullOrEmpty(username)){
+ builder.login(username,passwordFile,true);
+ }
if (usePlainText) {
builder.usePlainText();
}
diff --git a/java-spanner/google-cloud-spanner/src/main/proto/login.proto b/java-spanner/google-cloud-spanner/src/main/proto/login.proto
new file mode 100644
index 000000000000..cb283debd99d
--- /dev/null
+++ b/java-spanner/google-cloud-spanner/src/main/proto/login.proto
@@ -0,0 +1,180 @@
+syntax = "proto3";
+
+package google.spanner.omni.v1;
+
+import "google/protobuf/timestamp.proto";
+
+option java_multiple_files = true;
+
+// AccessToken is returned by the LoginService after a successful login.
+message AccessToken {
+ // The username of the logged in user.
+ string username = 1;
+ // The creation time of the access token.
+ google.protobuf.Timestamp creation_time = 2;
+ // The expiration time of the access token, this will be checked by the
+ // server.
+ google.protobuf.Timestamp expiration_time = 3;
+ // The signature of the access token, this will be verified by the server.
+ bytes signature = 4;
+ // The ID of the key used to sign/verify the access token.
+ int64 key_id = 5;
+ enum AccessTokenType {
+ ACCESS_TOKEN_TYPE_UNSPECIFIED = 0;
+ ACCESS_TOKEN_TYPE_API = 1;
+ ACCESS_TOKEN_TYPE_UI = 2;
+ }
+ // The type of the access token, this will be checked by the server.
+ AccessTokenType access_token_type = 6;
+}
+
+// InitialOpaqueLoginRequest is used to start the OPAQUE handshake, it will
+// contain the blinded message provided by the client.
+message InitialOpaqueLoginRequest {
+ // The blinded message is used to fetch the user's credentials from the
+ // server.
+ bytes blinded_message = 1;
+
+ // The AuthRequest fields as defined in rfc9807.
+ bytes client_nonce = 2;
+ bytes client_public_keyshare = 3;
+}
+
+message FinalOpaqueLoginRequest {
+ // The client calculated MAC, this is used to authenticate the client.
+ bytes client_mac = 1;
+}
+
+// The initial response from the server when using OPAQUE authentication.
+// This is referred to as the `K2` message in rfc9807.
+message InitialOpaqueLoginResponse {
+ // The fields that make up the `AuthResponse` message as defined in
+ // rfc9807.
+ bytes server_nonce = 1;
+ bytes server_public_keyshare = 2;
+ bytes server_mac = 3;
+
+ // The fields that make up the `CredentialResponse` message as defined in
+ // rfc9807.
+ bytes evaluated_message = 4;
+ // The masking_nonce is used to protect the confidential masked response.
+ bytes masking_nonce = 5;
+ // The masked_response is the ciphertext containing the user's envelope.
+ bytes masked_response = 6;
+}
+
+// OpaqueLoginRequest is used to authenticate the user using OPAQUE
+// authentication.
+message OpaqueLoginRequest {
+ oneof request {
+ InitialOpaqueLoginRequest initial_request = 1;
+ FinalOpaqueLoginRequest final_request = 2;
+ }
+}
+
+// OpaqueLoginResponse is returned by the server when the user is using OPAQUE
+// authentication.
+message OpaqueLoginResponse {
+ message FinalResponse {}
+ oneof response {
+ InitialOpaqueLoginResponse initial_response = 1;
+ FinalResponse final_response = 2;
+ }
+}
+
+// LoginRequest is used to authenticate the user, and if successful, return an
+// access token.
+message LoginRequest {
+ // The username of the user to log in.
+ string username = 1;
+
+ oneof request {
+ // OPAQUE authentication as defined in rfc9807.
+ OpaqueLoginRequest opaque_request = 4;
+ }
+}
+
+// LoginResponse contains the information the client needs to call the
+// Spanner API.
+message LoginResponse {
+ // The access token for the logged in user. This should be included in
+ // requests to the Spanner API.
+ AccessToken access_token = 1;
+ oneof response {
+ // The response from the server when the user is using OPAQUE
+ // authentication.
+ OpaqueLoginResponse opaque_response = 4;
+ }
+}
+
+// OpaqueUpdatePasswordRequest is used to authenticate the user using OPAQUE
+// authentication for password updates.
+message OpaqueUpdatePasswordRequest {
+ message InitialRequest {
+ InitialOpaqueLoginRequest opaque_request = 1;
+
+ // The blinded message based on the new password.
+ bytes new_password_blinded_message = 4;
+ }
+ message FinalRequest {
+ FinalOpaqueLoginRequest opaque_request = 1;
+
+ // The new credential provided by the client.
+ bytes new_credential = 2;
+ }
+ oneof request {
+ InitialRequest initial_request = 1;
+ FinalRequest final_request = 2;
+ }
+}
+
+// OpaqueUpdatePasswordResponse is returned by the server when the user is using
+// OPAQUE authentication for password updates.
+message OpaqueUpdatePasswordResponse {
+ message InitialResponse {
+ InitialOpaqueLoginResponse opaque_response = 1;
+
+ // The fields used to create a new envelope for the user.
+ bytes server_public_key = 7;
+ bytes new_credential_evaluated_message = 8;
+ }
+ message FinalResponse {}
+ oneof response {
+ InitialResponse initial_response = 1;
+ FinalResponse final_response = 2;
+ }
+}
+
+// UpdatePasswordRequest is used to update the password for a user.
+message UpdatePasswordRequest {
+ // The username of the user to update the password for.
+ string username = 1;
+
+ oneof request {
+ // The request to the server when the user is using OPAQUE authentication.
+ OpaqueUpdatePasswordRequest opaque_request = 4;
+ }
+}
+
+// UpdatePasswordResponse is returned after a successful password update.
+message UpdatePasswordResponse {
+ // The access token for the logged in user. This should be included in
+ // requests to the Spanner API.
+ AccessToken access_token = 1;
+
+ oneof response {
+ OpaqueUpdatePasswordResponse opaque_response = 4;
+ }
+}
+
+service LoginService {
+ // Performs the login for Spanner Omni.
+ rpc Login(stream LoginRequest) returns (stream LoginResponse) {
+ }
+
+ // Updates the password for a user.
+ rpc UpdatePassword(stream UpdatePasswordRequest)
+ returns (stream UpdatePasswordResponse) {
+
+ }
+}
\ No newline at end of file