Skip to content

feat: login functionality for experimental host (DRAFT) #3680

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -145,6 +145,7 @@
<module>proto-google-cloud-spanner-executor-v1</module>
<module>google-cloud-spanner-executor</module>
<module>google-cloud-spanner-bom</module>
<module>proto-google-cloud-spanner-host</module>
</modules>

<build>
77 changes: 77 additions & 0 deletions proto-google-cloud-spanner-host/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-spanner-parent</artifactId>
<version>6.88.0</version>
</parent>

<artifactId>proto-google-cloud-spanner-host</artifactId>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:LATEST:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:LATEST:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.0</version>
</extension>
</extensions>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2017 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;

import io.grpc.ChannelCredentials;
import io.grpc.Grpc;
import io.grpc.ManagedChannel;
import io.grpc.TlsChannelCredentials;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import spanner.experimental.LoginServiceGrpc;

public class GrpcClient {

private final ManagedChannel channel;
private final LoginServiceGrpc.LoginServiceStub loginService;
private X509Certificate serverCertificate; // Store the server certificate
private final int DEFAULT_PORT = 15000;

public GrpcClient(String endpoint) throws IOException {
this(endpoint, null, null);
}

public GrpcClient(String endpoint, String clientCertificate, String clientCertificateKey) {
try {
URI uri = new URI(endpoint);
String host = uri.getHost();
int port = (uri.getPort() == -1) ? DEFAULT_PORT : uri.getPort();

if (host == null) {
throw new IllegalArgumentException("Invalid endpoint: " + endpoint);
}

// Retrieve the server certificate during handshake
this.serverCertificate = fetchServerCertificate(host, port);

if (clientCertificate != null && clientCertificateKey != null) {
this.channel =
NettyChannelBuilder.forAddress(host, port)
.sslContext(
GrpcSslContexts.forClient()
.keyManager(
new File(clientCertificate),
new File(clientCertificateKey)) // Client auth
.build())
.build();
} else {
// Normal TLS
ChannelCredentials credentials = TlsChannelCredentials.newBuilder().build();
this.channel = Grpc.newChannelBuilderForAddress(host, port, credentials).build();
}
this.loginService = LoginServiceGrpc.newStub(channel);
} catch (URISyntaxException | IOException e) {
throw new RuntimeException(e);
}
}

/** Establish a TLS connection to fetch the server certificate. */
private X509Certificate fetchServerCertificate(String host, int port) throws IOException {
try {
SSLSocketFactory sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
try (SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(host, port)) {
socket.startHandshake();
Certificate[] serverCerts = socket.getSession().getPeerCertificates();
if (serverCerts.length > 0 && serverCerts[0] instanceof X509Certificate) {
return (X509Certificate) serverCerts[0]; // Store the first certificate
}
}
} catch (SSLPeerUnverifiedException e) {
throw new IOException("Failed to verify server certificate: " + e.getMessage(), e);
}
throw new IOException("No server certificate found");
}

public LoginServiceGrpc.LoginServiceStub getLoginService() {
return loginService;
}

public X509Certificate getServerCertificate() {
return serverCertificate;
}

public void close() {
if (channel != null) {
channel.shutdown();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2017 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;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import spanner.experimental.AccessToken;

public class LoginClient {

private final String username;
private final String password;
private final String endpoint;
private volatile AccessToken accessToken;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ReentrantLock refreshLock = new ReentrantLock();
private ScheduledFuture<?> scheduledTask; // Holds the scheduled task
private String clientCertificate = null;
private String clientCertificateKey = null;

private static final long TOKEN_REFRESH_THRESHOLD_SECONDS = 300; // Refresh 5 minutes before expiry

public LoginClient(String username, String password, String endpoint) throws Exception {
this(username, password, endpoint, null, null);
}

public LoginClient(
String username,
String password,
String endpoint,
String clientCertificate,
String clientCertificateKey) {
this.username = username;
this.password = password;
this.endpoint = endpoint;
if (clientCertificate != null && clientCertificateKey != null) {
this.clientCertificate = clientCertificate;
this.clientCertificateKey = clientCertificateKey;
}
login();
scheduleNextTokenRefresh();
}

private void login() {
try {
Scram scram =
new Scram(
this.username,
this.password,
new GrpcClient(this.endpoint, this.clientCertificate, this.clientCertificateKey));
this.accessToken = scram.login();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public AccessToken getAccessToken() {
return accessToken;
}

private void scheduleNextTokenRefresh() {
if (accessToken == null) return;
long delay =
(accessToken.getExpirationTime().getSeconds() - System.currentTimeMillis() / 1000)
- TOKEN_REFRESH_THRESHOLD_SECONDS;

if (delay <= 0) {
refreshToken();
return;
}
if (scheduledTask != null) {
scheduledTask.cancel(false);
}
// Schedule a new token refresh exactly when needed
scheduledTask = scheduler.schedule(this::refreshToken, delay, TimeUnit.SECONDS);
System.out.println("Next token refresh scheduled in " + delay + " seconds.");
}

private void refreshToken() {
if (!refreshLock.tryLock()) return; // Prevent multiple simultaneous refreshes

try {
System.out.println("Refreshing access token...");
login();
System.out.println("New token acquired.\n" + getAccessToken());
scheduleNextTokenRefresh();
} catch (Exception e) {
System.err.println("Token refresh failed: " + e.getMessage());
} finally {
refreshLock.unlock();
}
}

public void shutdown() {
System.out.println("Shutting down LoginClient...");
if (scheduledTask != null) {
scheduledTask.cancel(false); // Cancel any pending token refresh task
}
scheduler.shutdown();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
/*
* Copyright 2017 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;

import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.ByteString;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
import org.bouncycastle.crypto.params.Argon2Parameters;
import spanner.experimental.AccessToken;
import spanner.experimental.FinalScramRequest;
import spanner.experimental.FinalScramResponse;
import spanner.experimental.InitialScramRequest;
import spanner.experimental.InitialScramResponse;
import spanner.experimental.LoginRequest;
import spanner.experimental.LoginResponse;
import spanner.experimental.LoginServiceGrpc;

public class Scram {

private final String username;
private final String password;
private final byte[] clientNonce;
private final X509Certificate certificate;
private byte[] saltedPassword;
private byte[] authMessage;
private byte[] serverNonce;
private byte[] salt;
private int iterationCount;
private GrpcClient grpcClient;

private static final int ARGON_MEMORY_LIMIT = 64 * 1024;
private static final int ARGON_THREADS = 4;
private static final int ARGON_KEY_LENGTH = 32;
private static final String CLIENT_KEY_MESSAGE = "Client Key";
private static final String SERVER_KEY_MESSAGE = "Server Key";
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String SHA256 = "SHA-256";
private static final int NONCE_LENGTH = 16;

private final LoginServiceGrpc.LoginServiceStub loginService;

public Scram(String username, String password, GrpcClient grpcClient){
this.username = username;
this.password = password;
this.certificate = grpcClient.getServerCertificate();
this.clientNonce = nonce();
this.loginService = grpcClient.getLoginService();
this.grpcClient = grpcClient;
}

public AccessToken login() throws Exception {
InitialScramRequest initialScramRequest =
InitialScramRequest.newBuilder().setClientNonce(ByteString.copyFrom(clientNonce)).build();

final LoginRequest initialLoginRequest =
LoginRequest.newBuilder()
.setUsername(username)
.setInitialScramRequest(initialScramRequest)
.build();

AccessToken[] accessToken = new AccessToken[1];
final StreamObserver<LoginRequest>[] requestObserverContainer = new StreamObserver[1];
final CountDownLatch latch = new CountDownLatch(1);

requestObserverContainer[0] =
loginService.login(
new StreamObserver<LoginResponse>() {
@Override
public void onNext(LoginResponse loginResponse) {
try {
if (loginResponse.hasInitialScramResponse()) {
addServerFirstMessage(loginResponse.getInitialScramResponse());
byte[] clientProof =
clientProof(
initialLoginRequest.getInitialScramRequest(),
loginResponse.getInitialScramResponse());

LoginRequest finalLoginRequest =
LoginRequest.newBuilder()
.setUsername(username)
.setFinalScramRequest(
FinalScramRequest.newBuilder()
.setCredential(ByteString.copyFrom(clientProof))
.build())
.build();
requestObserverContainer[0].onNext(finalLoginRequest);

} else {
verifyLoginResponse(loginResponse.getFinalScramResponse());
accessToken[0] = loginResponse.getAccessToken();
latch.countDown(); // Signal completion
}
} catch (Exception e) {
System.err.println("Exception in onNext: " + e.getMessage());
requestObserverContainer[0].onError(e);
}
}

@Override
public void onError(Throwable t) {
System.err.println("Error during login: " + t.getMessage());
latch.countDown();
}

@Override
public void onCompleted() {
requestObserverContainer[0].onCompleted();
}
});

requestObserverContainer[0].onNext(initialLoginRequest);

try {
latch.await();
} catch (InterruptedException e) {
throw new Exception("Login process interrupted", e);
} finally {
grpcClient.close();
}

return accessToken[0];
}

private void addServerFirstMessage(InitialScramResponse initialScramResponse) throws Exception {
serverNonce = initialScramResponse.getServerNonce().toByteArray();
salt = initialScramResponse.getSalt().toByteArray();
iterationCount = initialScramResponse.getIterationCount();
if (salt.length == 0) {
throw new Exception("No salt found in the response");
}
if (iterationCount == 0) {
throw new Exception("No iteration count found in response");
}
}

private void verifyLoginResponse(FinalScramResponse finalScramResponse) throws Exception {
byte[] serverKey = serverKey(saltedPassword);
byte[] serverSignature = serverSignature(serverKey, authMessage);
byte[] candidateServerSignature = finalScramResponse.getServerSignature().toByteArray();
if (!Arrays.equals(serverSignature, candidateServerSignature)) {
throw new Exception("Server signature does not match");
}
}

private byte[] clientProof(
InitialScramRequest initialScramRequest, InitialScramResponse initialScramResponse)
throws Exception {
saltedPassword =
saltPassword(
password,
initialScramResponse.getSalt().toByteArray(),
initialScramResponse.getIterationCount());
byte[] clientKey = clientKey(saltedPassword);
byte[] storedKey = storedKey(clientKey);
authMessage =
authMessage(
username, initialScramRequest, initialScramResponse, certificate.getSignature());
byte[] clientSignature = clientSignature(storedKey, authMessage);
return xorBytes(clientKey, clientSignature);
}

@VisibleForTesting
public static byte[] saltPassword(String password, byte[] salt, int iterationCount)
throws Exception {
if (salt.length == 0) {
throw new Exception("No salt found");
}
if (iterationCount == 0) {
throw new Exception("No iteration count found");
}
Argon2Parameters parameters =
new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withSalt(salt)
.withIterations(iterationCount)
.withMemoryAsKB(ARGON_MEMORY_LIMIT)
.withParallelism(ARGON_THREADS)
.build();
Argon2BytesGenerator argon2BytesGenerator = new Argon2BytesGenerator();
argon2BytesGenerator.init(parameters);
byte[] saltedPassword = new byte[ARGON_KEY_LENGTH];
argon2BytesGenerator.generateBytes(
password.getBytes(StandardCharsets.UTF_8), saltedPassword, 0, saltedPassword.length);

return saltedPassword;
}

@VisibleForTesting
public static byte[] nonce() {
byte[] nonce = new byte[NONCE_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(nonce);
return nonce;
}

private static byte[] xorBytes(byte[] a, byte[] b) {
int minLength = Math.min(a.length, b.length);
byte[] result = new byte[minLength];
for (int i = 0; i < minLength; i++) {
result[i] = (byte) (a[i] ^ b[i]);
}
return result;
}

private static byte[] clientSignature(byte[] storedKey, byte[] authMessage) throws Exception {
try {
Mac hmac = Mac.getInstance(HMAC_SHA256);
hmac.init(new SecretKeySpec(storedKey, HMAC_SHA256));
hmac.update(authMessage);
return hmac.doFinal();
} catch (java.security.GeneralSecurityException e) {
throw new Exception("Failed to generate client signature due to: " + e.getMessage(), e);
}
}

private static byte[] clientKey(byte[] saltedPassword) throws Exception {
return hmacSha256(saltedPassword, CLIENT_KEY_MESSAGE.getBytes());
}

private static byte[] serverKey(byte[] saltedPassword) throws Exception {
return hmacSha256(saltedPassword, SERVER_KEY_MESSAGE.getBytes());
}

private static byte[] hmacSha256(byte[] key, byte[] message) throws Exception {
try {
Mac hmac = Mac.getInstance(HMAC_SHA256);
hmac.init(new SecretKeySpec(key, HMAC_SHA256));
hmac.update(message);
return hmac.doFinal();
} catch (java.security.GeneralSecurityException e) {
throw new Exception("Failed to create hmac-sha256 due to: " + e.getMessage(), e);
}
}

private static byte[] serverSignature(byte[] serverKey, byte[] authMessage) throws Exception {
return hmacSha256(serverKey, authMessage);
}

private static byte[] storedKey(byte[] clientKey) throws Exception {
try {
MessageDigest digest = MessageDigest.getInstance(SHA256);
return digest.digest(clientKey);
} catch (NoSuchAlgorithmException e) {
throw new Exception("Failed to create stored key due to: " + e.getMessage(), e);
}
}

private static byte[] authMessage(
String username,
InitialScramRequest initialScramRequest,
InitialScramResponse initialScramResponse,
byte[] certificateSignature) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
outputStream.write(username.getBytes(StandardCharsets.UTF_8));
outputStream.write(',');
outputStream.write(initialScramRequest.getClientNonce().toByteArray());
outputStream.write(',');
outputStream.write(initialScramRequest.getClientNonce().toByteArray());
outputStream.write(',');
outputStream.write(initialScramResponse.getServerNonce().toByteArray());
outputStream.write(',');
outputStream.write(initialScramResponse.getSalt().toByteArray());
outputStream.write(',');
outputStream.write(
String.valueOf(initialScramResponse.getIterationCount())
.getBytes(StandardCharsets.UTF_8));
outputStream.write(',');
outputStream.write(certificateSignature);
outputStream.write(',');
outputStream.write(initialScramRequest.getClientNonce().toByteArray());
outputStream.write(',');
outputStream.write(initialScramResponse.getServerNonce().toByteArray());
} catch (IOException e) {
throw new RuntimeException("Failed to construct authMessage", e);
}
return outputStream.toByteArray();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
syntax = "proto3";

package spanner.experimental;

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;
}

// InitialScramRequest is used to start the SCRAM handshake, it will contain the
// nonce provided by the client.
message InitialScramRequest {
// Nonce provided by the client.
bytes client_nonce = 1;
}

// FinalScramRequest is used to complete the SCRAM handshake, it will contain
// the credential provided by the client.
message FinalScramRequest {
// The credential provided by the client.
bytes credential = 1;
}

// InitialScramResponse is returned by the server so that the client can
// generate the credential.
message InitialScramResponse {
// The salt to use when hashing the password.
bytes salt = 1;
// The number of iterations to use when hashing the password.
uint32 iteration_count = 2;
// Nonce provided by the server.
bytes server_nonce = 3;
}

// FinalScramResponse is returned by the server after it has verified the
// client credential.
message FinalScramResponse {
// The server_signature from the server.
bytes server_signature = 1;
}

// 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 {
InitialScramRequest initial_scram_request = 2;
FinalScramRequest final_scram_request = 3;
}
}

// 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 {
InitialScramResponse initial_scram_response = 2;
FinalScramResponse final_scram_response = 3;
}
}

service LoginService {
// Performs the login for rfc5802 authentication.
rpc Login(stream LoginRequest) returns (stream LoginResponse);
}
64 changes: 64 additions & 0 deletions proto-google-cloud-spanner-host/src/test/java/ScramTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2017 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.
*/

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;

import com.google.cloud.Scram;
import com.google.protobuf.ByteString;
import org.junit.Test;

public class ScramTest {

@Test
public void testNonceGeneration() {
byte[] nonce1 = Scram.nonce();
byte[] nonce2 = Scram.nonce();
assertNotNull(nonce1);
assertNotNull(nonce2);
assertEquals(16, nonce1.length);
assertEquals(16, nonce2.length);
assertNotEquals(ByteString.copyFrom(nonce1), ByteString.copyFrom(nonce2));
}

@Test
public void testSaltPassword() throws Exception {
byte[] salt = "randomSalt".getBytes();
int iterations = 3;
byte[] hashedPassword = Scram.saltPassword("testPass", salt, iterations);
assertNotNull(hashedPassword);
assertEquals(32, hashedPassword.length);

byte[] emptySalt = new byte[0];
Exception saltException =
assertThrows(
Exception.class,
() -> {
Scram.saltPassword("testPass", emptySalt, iterations);
});
assertEquals("No salt found", saltException.getMessage());

Exception iterationException =
assertThrows(
Exception.class,
() -> {
Scram.saltPassword("testPass", salt, 0);
});
assertEquals("No iteration count found", iterationException.getMessage());
}
}