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