diff --git a/pom.xml b/pom.xml index 92a7a25e071..6fb34ccb198 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,7 @@ proto-google-cloud-spanner-executor-v1 google-cloud-spanner-executor google-cloud-spanner-bom + proto-google-cloud-spanner-host diff --git a/proto-google-cloud-spanner-host/pom.xml b/proto-google-cloud-spanner-host/pom.xml new file mode 100644 index 00000000000..dfd1fd9a248 --- /dev/null +++ b/proto-google-cloud-spanner-host/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + com.google.cloud + google-cloud-spanner-parent + 6.88.0 + + + proto-google-cloud-spanner-host + + + 17 + 17 + UTF-8 + + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + com.google.protobuf + protobuf-java + + + org.bouncycastle + bcprov-jdk15on + 1.70 + + + io.grpc + grpc-netty-shaded + + + + junit + junit + test + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:LATEST:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:LATEST:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.0 + + + + \ No newline at end of file diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java new file mode 100644 index 00000000000..44c11d02bd9 --- /dev/null +++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java @@ -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(); + } + } +} diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java new file mode 100644 index 00000000000..a7daefe357e --- /dev/null +++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java @@ -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(); + } +} diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java new file mode 100644 index 00000000000..72a82ceee5f --- /dev/null +++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java @@ -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[] requestObserverContainer = new StreamObserver[1]; + final CountDownLatch latch = new CountDownLatch(1); + + requestObserverContainer[0] = + loginService.login( + new StreamObserver() { + @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(); + } +} diff --git a/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto b/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto new file mode 100644 index 00000000000..68af651f2ad --- /dev/null +++ b/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto @@ -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); +} \ No newline at end of file diff --git a/proto-google-cloud-spanner-host/src/test/java/ScramTest.java b/proto-google-cloud-spanner-host/src/test/java/ScramTest.java new file mode 100644 index 00000000000..8c2b6518ea2 --- /dev/null +++ b/proto-google-cloud-spanner-host/src/test/java/ScramTest.java @@ -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()); + } +}