From 687346b6b112863d170120949369ce1cd9b96c4e Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Thu, 19 Mar 2026 18:26:52 +0000 Subject: [PATCH 1/4] RFC 7804 - SCRAM Authentication Implementation with SHA-256 --- .../main/java/org/asynchttpclient/Dsl.java | 9 +- .../main/java/org/asynchttpclient/Realm.java | 34 +- .../netty/NettyResponseFuture.java | 10 + .../netty/handler/intercept/Interceptors.java | 46 +++ .../ProxyUnauthorized407Interceptor.java | 66 ++++ .../intercept/Redirect30xInterceptor.java | 4 +- .../intercept/Unauthorized401Interceptor.java | 68 ++++ .../netty/request/NettyRequestSender.java | 8 +- .../asynchttpclient/scram/ScramContext.java | 276 ++++++++++++++ .../asynchttpclient/scram/ScramEngine.java | 258 ++++++++++++++ .../asynchttpclient/scram/ScramException.java | 30 ++ .../scram/ScramMessageFormatter.java | 93 +++++ .../scram/ScramMessageParser.java | 261 ++++++++++++++ .../scram/ScramSessionCache.java | 192 ++++++++++ .../org/asynchttpclient/scram/ScramState.java | 28 ++ .../util/AuthenticatorUtils.java | 12 + .../org/asynchttpclient/ScramAuthTest.java | 337 ++++++++++++++++++ .../scram/ScramContextTest.java | 180 ++++++++++ .../scram/ScramEngineTest.java | 231 ++++++++++++ .../scram/ScramMessageFormatterTest.java | 116 ++++++ .../scram/ScramMessageParserTest.java | 176 +++++++++ .../scram/ScramRfc7804GoldenTest.java | 210 +++++++++++ .../scram/ScramSessionCacheTest.java | 187 ++++++++++ 23 files changed, 2825 insertions(+), 7 deletions(-) create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramContext.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramEngine.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramException.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java create mode 100644 client/src/main/java/org/asynchttpclient/scram/ScramState.java create mode 100644 client/src/test/java/org/asynchttpclient/ScramAuthTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java create mode 100644 client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java diff --git a/client/src/main/java/org/asynchttpclient/Dsl.java b/client/src/main/java/org/asynchttpclient/Dsl.java index 159c8c9fae..3295601291 100644 --- a/client/src/main/java/org/asynchttpclient/Dsl.java +++ b/client/src/main/java/org/asynchttpclient/Dsl.java @@ -113,7 +113,9 @@ public static Realm.Builder realm(Realm prototype) { .setUseCanonicalHostname(prototype.isUseCanonicalHostname()) .setCustomLoginConfig(prototype.getCustomLoginConfig()) .setLoginContextName(prototype.getLoginContextName()) - .setUserhash(prototype.isUserhash()); + .setUserhash(prototype.isUserhash()) + .setSid(prototype.getSid()) + .setMaxIterationCount(prototype.getMaxIterationCount()); // Note: stale is NOT copied — it's challenge-specific, always starts false } @@ -132,4 +134,9 @@ public static Realm.Builder digestAuthRealm(String principal, String password) { public static Realm.Builder ntlmAuthRealm(String principal, String password) { return realm(AuthScheme.NTLM, principal, password); } + + public static Realm.Builder scramSha256AuthRealm(String principal, String password) { + return realm(AuthScheme.SCRAM_SHA_256, principal, password); + } + } diff --git a/client/src/main/java/org/asynchttpclient/Realm.java b/client/src/main/java/org/asynchttpclient/Realm.java index 123e272d1f..768a72f6ac 100644 --- a/client/src/main/java/org/asynchttpclient/Realm.java +++ b/client/src/main/java/org/asynchttpclient/Realm.java @@ -69,6 +69,8 @@ public class Realm { private final @Nullable String loginContextName; private final boolean stale; private final boolean userhash; + private final @Nullable String sid; + private final int maxIterationCount; private Realm(@Nullable AuthScheme scheme, @Nullable String principal, @@ -93,7 +95,9 @@ private Realm(@Nullable AuthScheme scheme, @Nullable Map customLoginConfig, @Nullable String loginContextName, boolean stale, - boolean userhash) { + boolean userhash, + @Nullable String sid, + int maxIterationCount) { this.scheme = requireNonNull(scheme, "scheme"); this.principal = principal; @@ -119,6 +123,8 @@ private Realm(@Nullable AuthScheme scheme, this.loginContextName = loginContextName; this.stale = stale; this.userhash = userhash; + this.sid = sid; + this.maxIterationCount = maxIterationCount; } public @Nullable String getPrincipal() { @@ -232,6 +238,14 @@ public boolean isUserhash() { return userhash; } + public @Nullable String getSid() { + return sid; + } + + public int getMaxIterationCount() { + return maxIterationCount; + } + @Override public String toString() { return "Realm{" + @@ -261,7 +275,7 @@ public String toString() { } public enum AuthScheme { - BASIC, DIGEST, NTLM, SPNEGO, KERBEROS + BASIC, DIGEST, NTLM, SPNEGO, KERBEROS, SCRAM_SHA_256 } /** @@ -300,6 +314,8 @@ public static class Builder { private boolean stale; private boolean userhash; private @Nullable String entityBodyHash; + private @Nullable String sid; + private int maxIterationCount = 16_384; public Builder() { principal = null; @@ -432,6 +448,16 @@ public Builder setEntityBodyHash(@Nullable String entityBodyHash) { return this; } + public Builder setSid(@Nullable String sid) { + this.sid = sid; + return this; + } + + public Builder setMaxIterationCount(int maxIterationCount) { + this.maxIterationCount = maxIterationCount; + return this; + } + public @Nullable String getQopValue() { return qop; } @@ -720,7 +746,9 @@ public Realm build() { customLoginConfig, loginContextName, stale, - userhash); + userhash, + sid, + maxIterationCount); } } } diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java index c29c0f33d9..8cbbcfc503 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java @@ -27,6 +27,7 @@ import org.asynchttpclient.netty.request.NettyRequest; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.scram.ScramContext; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -126,6 +127,7 @@ public final class NettyResponseFuture implements ListenableFuture { private boolean allowConnect; private Realm realm; private Realm proxyRealm; + private volatile ScramContext scramContext; public NettyResponseFuture(Request originalRequest, AsyncHandler asyncHandler, @@ -540,6 +542,14 @@ public void setProxyRealm(Realm proxyRealm) { this.proxyRealm = proxyRealm; } + public ScramContext getScramContext() { + return scramContext; + } + + public void setScramContext(ScramContext scramContext) { + this.scramContext = scramContext; + } + @Override public String toString() { return "NettyResponseFuture{" + // diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java index f11530b8d0..69f58b3737 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java @@ -32,11 +32,17 @@ import org.asynchttpclient.netty.channel.ChannelManager; import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.scram.ScramContext; +import org.asynchttpclient.scram.ScramMessageParser; +import org.asynchttpclient.scram.ScramState; import org.asynchttpclient.util.AuthenticatorUtils; import org.asynchttpclient.util.NonceCounter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE; import static org.asynchttpclient.Dsl.realm; import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.CONTINUE_100; @@ -128,6 +134,14 @@ public boolean exitAfterIntercept(Channel channel, NettyResponseFuture future processAuthenticationInfo(future, responseHeaders, proxyRealm, true); } + // Process SCRAM Authentication-Info (RFC 7804 §5) + if (realm != null && realm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) { + processScramAuthenticationInfo(future, responseHeaders, "Authentication-Info"); + } + if (proxyRealm != null && proxyRealm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) { + processScramAuthenticationInfo(future, responseHeaders, "Proxy-Authentication-Info"); + } + return false; } @@ -166,4 +180,36 @@ private void processAuthenticationInfo(NettyResponseFuture future, HttpHeader } } } + + private void processScramAuthenticationInfo(NettyResponseFuture future, HttpHeaders responseHeaders, + String headerName) { + ScramContext ctx = future.getScramContext(); + if (ctx == null || ctx.getState() != ScramState.CLIENT_FINAL_SENT) { + return; + } + + String authInfo = responseHeaders.get(headerName); + if (authInfo == null) { + // RFC 7804 §6: may be in chunked trailers (not supported by AHC) + LOGGER.warn("SCRAM: response without {} header; " + + "ServerSignature cannot be verified (may be in chunked trailers)", headerName); + return; + } + + String data = Realm.Builder.matchParam(authInfo, "data"); + if (data == null) { + LOGGER.warn("SCRAM: Authentication-Info header missing data attribute"); + return; + } + + String serverFinalMsg = new String(Base64.getDecoder().decode(data), StandardCharsets.UTF_8); + if (ctx.verifyServerFinal(serverFinalMsg)) { + ctx.setState(ScramState.AUTHENTICATED); + LOGGER.debug("SCRAM ServerSignature verified successfully"); + } else { + ctx.setState(ScramState.FAILED); + LOGGER.warn("SCRAM ServerSignature verification failed — authentication unsuccessful " + + "(RFC 7804 §5: MUST consider unsuccessful)"); + } + } } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java index 87b08f6905..62ea640274 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java @@ -32,6 +32,11 @@ import io.netty.handler.codec.http2.Http2StreamChannel; import org.asynchttpclient.ntlm.NtlmEngine; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.scram.ScramContext; +import org.asynchttpclient.scram.ScramException; +import org.asynchttpclient.scram.ScramMessageFormatter; +import org.asynchttpclient.scram.ScramMessageParser; +import org.asynchttpclient.scram.ScramState; import org.asynchttpclient.spnego.SpnegoEngine; import org.asynchttpclient.spnego.SpnegoEngineException; import org.asynchttpclient.util.AuthenticatorUtils; @@ -39,6 +44,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; import static io.netty.handler.codec.http.HttpHeaderNames.PROXY_AUTHENTICATE; @@ -214,6 +221,65 @@ public boolean exitAfterHandling407(Channel channel, NettyResponseFuture futu } } break; + case SCRAM_SHA_256: + String scramPrefix = "SCRAM-SHA-256"; + String scramHeader = getHeaderWithPrefix(proxyAuthHeaders, scramPrefix); + if (scramHeader == null) { + LOGGER.info("Can't handle 407 with SCRAM realm as Proxy-Authenticate headers don't match"); + return false; + } + + try { + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(scramHeader); + ScramContext ctx = future.getScramContext(); + + if (ctx == null) { + ctx = new ScramContext(proxyRealm.getPrincipal(), proxyRealm.getPassword(), + params.realm != null ? params.realm : proxyRealm.getRealmName(), + scramPrefix); + ctx.setInitialChallengeParams(params); + + String base64Data = Base64.getEncoder().encodeToString( + ctx.getClientFirstMessage().getBytes(StandardCharsets.UTF_8)); + String authHeader = ScramMessageFormatter.formatAuthorizationHeader( + ctx.getMechanism(), ctx.getRealmName(), null, base64Data); + + requestHeaders.set(PROXY_AUTHORIZATION, authHeader); + future.setScramContext(ctx); + future.setInProxyAuth(false); + + } else if (ctx.getState() == ScramState.CLIENT_FIRST_SENT) { + if (params.sid == null) { + LOGGER.warn("SCRAM: missing sid in proxy server-first response"); + return false; + } + if (params.data == null) { + LOGGER.warn("SCRAM: missing data in proxy server-first response"); + return false; + } + + String serverFirstMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + ctx.processServerFirst(serverFirstMsg, proxyRealm.getMaxIterationCount()); + ctx.setSid(params.sid); + + String clientFinalMsg = ctx.computeClientFinal(); + String base64Data = Base64.getEncoder().encodeToString( + clientFinalMsg.getBytes(StandardCharsets.UTF_8)); + String authHeader = ScramMessageFormatter.formatAuthorizationHeader( + ctx.getMechanism(), null, params.sid, base64Data); + + requestHeaders.set(PROXY_AUTHORIZATION, authHeader); + + } else { + LOGGER.warn("SCRAM proxy authentication failed: unexpected 407 in state {}", ctx.getState()); + return false; + } + } catch (ScramException e) { + LOGGER.warn("SCRAM proxy authentication failed: {}", e.getMessage()); + return false; + } + break; + default: throw new IllegalStateException("Invalid Authentication scheme " + proxyRealm.getScheme()); } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java index 01bbb265b8..1016559b89 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java @@ -95,6 +95,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture // We must allow auth handling again. future.setInAuth(false); future.setInProxyAuth(false); + future.setScramContext(null); String originalMethod = request.getMethod(); boolean switchToGet = !originalMethod.equals(GET) && @@ -196,7 +197,8 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole headers.remove(CONTENT_TYPE); } - if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) { + if (stripAuthorization || (realm != null && (realm.getScheme() == AuthScheme.NTLM + || realm.getScheme() == AuthScheme.SCRAM_SHA_256))) { headers.remove(AUTHORIZATION) .remove(PROXY_AUTHORIZATION); } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java index 7b1dc2ee34..0b6f45b2c0 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java @@ -30,6 +30,11 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import io.netty.handler.codec.http2.Http2StreamChannel; import org.asynchttpclient.ntlm.NtlmEngine; +import org.asynchttpclient.scram.ScramContext; +import org.asynchttpclient.scram.ScramException; +import org.asynchttpclient.scram.ScramMessageFormatter; +import org.asynchttpclient.scram.ScramMessageParser; +import org.asynchttpclient.scram.ScramState; import org.asynchttpclient.spnego.SpnegoEngine; import org.asynchttpclient.spnego.SpnegoEngineException; import org.asynchttpclient.uri.Uri; @@ -38,6 +43,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; @@ -206,6 +213,67 @@ public boolean exitAfterHandling401(Channel channel, NettyResponseFuture futu } } break; + case SCRAM_SHA_256: + String scramPrefix = "SCRAM-SHA-256"; + String scramHeader = getHeaderWithPrefix(wwwAuthHeaders, scramPrefix); + if (scramHeader == null) { + LOGGER.info("Can't handle 401 with SCRAM realm as WWW-Authenticate headers don't match"); + return false; + } + + try { + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(scramHeader); + ScramContext ctx = future.getScramContext(); + + if (ctx == null) { + // Step 1: First 401 — send client-first + ctx = new ScramContext(realm.getPrincipal(), realm.getPassword(), + params.realm != null ? params.realm : realm.getRealmName(), + scramPrefix); + ctx.setInitialChallengeParams(params); + + String base64Data = Base64.getEncoder().encodeToString( + ctx.getClientFirstMessage().getBytes(StandardCharsets.UTF_8)); + String authHeader = ScramMessageFormatter.formatAuthorizationHeader( + ctx.getMechanism(), ctx.getRealmName(), null, base64Data); + + requestHeaders.set(AUTHORIZATION, authHeader); + future.setScramContext(ctx); + future.setInAuth(false); // allow second 401 + + } else if (ctx.getState() == ScramState.CLIENT_FIRST_SENT) { + // Step 2: Second 401 — parse server-first, send client-final + if (params.sid == null) { + LOGGER.warn("SCRAM: missing sid in server-first response"); + return false; + } + if (params.data == null) { + LOGGER.warn("SCRAM: missing data in server-first response"); + return false; + } + + String serverFirstMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + ctx.processServerFirst(serverFirstMsg, realm.getMaxIterationCount()); + ctx.setSid(params.sid); + + String clientFinalMsg = ctx.computeClientFinal(); + String base64Data = Base64.getEncoder().encodeToString( + clientFinalMsg.getBytes(StandardCharsets.UTF_8)); + String authHeader = ScramMessageFormatter.formatAuthorizationHeader( + ctx.getMechanism(), null, params.sid, base64Data); + + requestHeaders.set(AUTHORIZATION, authHeader); + + } else { + LOGGER.warn("SCRAM authentication failed: unexpected 401 in state {}", ctx.getState()); + return false; + } + } catch (ScramException e) { + LOGGER.warn("SCRAM authentication failed: {}", e.getMessage()); + return false; + } + break; + default: throw new IllegalStateException("Invalid Authentication scheme " + realm.getScheme()); } diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index b60fd8af45..08451ce953 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -305,8 +305,12 @@ private ListenableFuture sendRequestWithNewChannel(Request request, Proxy requestFactory.addAuthorizationHeader(headers, perConnectionAuthorizationHeader(request, proxy, realm)); requestFactory.setProxyAuthorizationHeader(headers, perConnectionProxyAuthorizationHeader(request, proxyRealm)); - future.setInAuth(realm != null && realm.isUsePreemptiveAuth() && realm.getScheme() != AuthScheme.NTLM); - future.setInProxyAuth(proxyRealm != null && proxyRealm.isUsePreemptiveAuth() && proxyRealm.getScheme() != AuthScheme.NTLM); + future.setInAuth(realm != null && realm.isUsePreemptiveAuth() + && realm.getScheme() != AuthScheme.NTLM + && realm.getScheme() != AuthScheme.SCRAM_SHA_256); + future.setInProxyAuth(proxyRealm != null && proxyRealm.isUsePreemptiveAuth() + && proxyRealm.getScheme() != AuthScheme.NTLM + && proxyRealm.getScheme() != AuthScheme.SCRAM_SHA_256); try { if (!channelManager.isOpen()) { diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramContext.java b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java new file mode 100644 index 0000000000..2b85b6516c --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.jetbrains.annotations.Nullable; + +import java.security.MessageDigest; +import java.util.Base64; + +import static java.util.Objects.requireNonNull; + +/** + * Per-exchange mutable state for a SCRAM authentication handshake (RFC 7804). + * Attached to NettyResponseFuture during a SCRAM exchange. + * Not thread-safe: accessed only from EventLoop. + */ +public class ScramContext { + + private ScramState state; + private final String mechanism; + private final String username; + private @Nullable String password; + private final @Nullable String realmName; + private final String clientNonce; + private @Nullable String serverNonce; + private @Nullable String sid; + private @Nullable byte[] salt; + private int iterationCount; + private final String clientFirstMessage; + private final String clientFirstMessageBare; + private @Nullable String serverFirstMessage; + private @Nullable String clientFinalMessageWithoutProof; + private @Nullable byte[] clientKey; + private @Nullable byte[] storedKey; + private @Nullable byte[] serverKey; + private @Nullable ScramMessageParser.ScramChallengeParams initialChallengeParams; + + /** + * Create a ScramContext and initialize the client-first step. + */ + public ScramContext(String username, String password, @Nullable String realmName, String mechanism) { + this.username = username; + this.password = password; + this.realmName = realmName; + this.mechanism = mechanism; + this.clientNonce = ScramEngine.generateNonce(24); + this.clientFirstMessage = ScramMessageFormatter.formatClientFirstMessage(username, clientNonce); + this.clientFirstMessageBare = ScramMessageFormatter.clientFirstMessageBare(username, clientNonce); + this.state = ScramState.CLIENT_FIRST_SENT; + } + + /** + * Process the server-first-message: validate nonce, extract salt/iterations, + * compute derived keys, and zero SaltedPassword. + * + * @param serverFirstMsg the verbatim server-first-message (decoded from base64) + * @param maxIterationCount maximum allowed iteration count for DoS protection + */ + public void processServerFirst(String serverFirstMsg, int maxIterationCount) { + this.serverFirstMessage = serverFirstMsg; + + ScramMessageParser.ServerFirstMessage parsed = ScramMessageParser.parseServerFirst(serverFirstMsg); + + // Validate nonce prefix + ScramMessageParser.validateNoncePrefix(clientNonce, parsed.fullNonce); + + // Validate iteration count + if (parsed.iterationCount > maxIterationCount) { + throw new ScramException("Server iteration count " + parsed.iterationCount + + " exceeds maximum allowed " + maxIterationCount); + } + + this.serverNonce = parsed.fullNonce; + this.salt = parsed.salt; + this.iterationCount = parsed.iterationCount; + + // Compute derived keys + String pwd = requireNonNull(password, "password already consumed"); + byte[] saltedPassword = ScramEngine.computeSaltedPassword(pwd, parsed.salt, iterationCount); + try { + this.clientKey = ScramEngine.computeClientKey(saltedPassword); + this.storedKey = ScramEngine.computeStoredKey(this.clientKey); + this.serverKey = ScramEngine.computeServerKey(saltedPassword); + } finally { + // Zero SaltedPassword immediately after deriving keys (RFC 7804 §8) + ScramEngine.zeroBytes(saltedPassword); + // Zero password in memory + this.password = null; + } + + this.state = ScramState.SERVER_FIRST_RECEIVED; + } + + /** + * Compute the client-final-message with proof. + * + * @return the full client-final-message string + */ + public String computeClientFinal() { + String fullNonce = requireNonNull(serverNonce, "serverNonce not set"); + String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set"); + byte[] currentStoredKey = requireNonNull(storedKey, "storedKey not set"); + byte[] currentClientKey = requireNonNull(clientKey, "clientKey not set"); + + this.clientFinalMessageWithoutProof = ScramMessageFormatter.clientFinalMessageWithoutProof(fullNonce); + + // AuthMessage = client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof + String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalMessageWithoutProof; + + byte[] clientSignature = ScramEngine.computeClientSignature(currentStoredKey, authMessage); + byte[] clientProof = ScramEngine.computeClientProof(currentClientKey, clientSignature); + + String clientFinal = ScramMessageFormatter.formatClientFinalMessage(fullNonce, clientProof); + this.state = ScramState.CLIENT_FINAL_SENT; + return clientFinal; + } + + /** + * Verify the server-final-message (ServerSignature). + * + * @param serverFinalMsg the decoded server-final-message + * @return true if ServerSignature is valid, false otherwise + */ + public boolean verifyServerFinal(String serverFinalMsg) { + ScramMessageParser.ServerFinalMessage parsed = ScramMessageParser.parseServerFinal(serverFinalMsg); + + if (parsed.error != null) { + this.state = ScramState.FAILED; + return false; + } + + if (parsed.verifier == null) { + this.state = ScramState.FAILED; + return false; + } + + String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set"); + String clientFinalNoProof = requireNonNull(clientFinalMessageWithoutProof, "clientFinalMessageWithoutProof not set"); + byte[] currentServerKey = requireNonNull(serverKey, "serverKey not set"); + + // Reconstruct AuthMessage + String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalNoProof; + + byte[] expectedServerSignature = ScramEngine.computeServerSignature(currentServerKey, authMessage); + byte[] receivedSignature = Base64.getDecoder().decode(parsed.verifier); + + // Constant-time comparison to prevent timing side-channel attacks + if (MessageDigest.isEqual(expectedServerSignature, receivedSignature)) { + this.state = ScramState.AUTHENTICATED; + return true; + } else { + this.state = ScramState.FAILED; + return false; + } + } + + /** + * Create a session cache entry from the current context after successful authentication. + */ + public ScramSessionCache.Entry toSessionCacheEntry(@Nullable String serverNoncePart, int ttl) { + byte[] currentSalt = requireNonNull(salt, "salt not set"); + byte[] currentClientKey = requireNonNull(clientKey, "clientKey not set"); + byte[] currentStoredKey = requireNonNull(storedKey, "storedKey not set"); + byte[] currentServerKey = requireNonNull(serverKey, "serverKey not set"); + String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set"); + + return new ScramSessionCache.Entry( + realmName, + currentSalt, + iterationCount, + currentClientKey, + currentStoredKey, + currentServerKey, + serverNoncePart, + ttl, + System.nanoTime(), + iterationCount, // nonce-count starts at iteration count per RFC 7804 §5.1 + serverFirst + ); + } + + // Getters and setters + + public ScramState getState() { + return state; + } + + public void setState(ScramState state) { + this.state = state; + } + + public String getMechanism() { + return mechanism; + } + + public String getUsername() { + return username; + } + + public @Nullable String getRealmName() { + return realmName; + } + + public String getClientNonce() { + return clientNonce; + } + + public @Nullable String getServerNonce() { + return serverNonce; + } + + public @Nullable String getSid() { + return sid; + } + + public void setSid(@Nullable String sid) { + this.sid = sid; + } + + public String getClientFirstMessage() { + return clientFirstMessage; + } + + public String getClientFirstMessageBare() { + return clientFirstMessageBare; + } + + public @Nullable String getServerFirstMessage() { + return serverFirstMessage; + } + + public @Nullable byte[] getClientKey() { + return clientKey; + } + + public @Nullable byte[] getStoredKey() { + return storedKey; + } + + public @Nullable byte[] getServerKey() { + return serverKey; + } + + public @Nullable ScramMessageParser.ScramChallengeParams getInitialChallengeParams() { + return initialChallengeParams; + } + + public void setInitialChallengeParams(@Nullable ScramMessageParser.ScramChallengeParams params) { + this.initialChallengeParams = params; + } + + public int getIterationCount() { + return iterationCount; + } + + public @Nullable byte[] getSalt() { + return salt; + } + + public @Nullable String getClientFinalMessageWithoutProof() { + return clientFinalMessageWithoutProof; + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java b/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java new file mode 100644 index 0000000000..97c62aaf8b --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.text.Normalizer; +import java.util.Arrays; +import java.util.Base64; + +/** + * Core SCRAM-SHA-256 cryptographic operations (RFC 5802, RFC 7804). + * Thread-safe: all methods are stateless. + */ +public final class ScramEngine { + + private static final String PBKDF2_ALGO = "PBKDF2WithHmacSHA256"; + private static final String HMAC_ALGO = "HmacSHA256"; + private static final String HASH_ALGO = "SHA-256"; + private static final int KEY_LENGTH_BITS = 256; + + private static final ThreadLocal SECURE_RANDOM = ThreadLocal.withInitial(SecureRandom::new); + + private ScramEngine() { + } + + /** + * PBKDF2 / Hi() computation as defined in RFC 5802. + * Hi(str, salt, i) = PBKDF2(str, salt, i, dkLen) + * + * @param normalizedPassword the normalized password (String, UTF-8 encoded internally) + * @param salt the salt bytes + * @param iterations the iteration count + * @return the derived key bytes + */ + public static byte[] hi(String normalizedPassword, byte[] salt, int iterations) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_ALGO); + PBEKeySpec spec = new PBEKeySpec(normalizedPassword.toCharArray(), salt, iterations, KEY_LENGTH_BITS); + try { + return factory.generateSecret(spec).getEncoded(); + } finally { + spec.clearPassword(); + } + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new ScramException("PBKDF2 computation failed", e); + } + } + + /** + * Compute HMAC-SHA-256. + */ + public static byte[] hmac(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(key, HMAC_ALGO)); + return mac.doFinal(data); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new ScramException("HMAC computation failed", e); + } + } + + /** + * Compute SHA-256 hash. + */ + public static byte[] hash(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance(HASH_ALGO); + return md.digest(data); + } catch (NoSuchAlgorithmException e) { + throw new ScramException("Hash computation failed", e); + } + } + + /** + * XOR two equal-length byte arrays. Mutates array {@code a} in-place. + */ + public static byte[] xor(byte[] a, byte[] b) { + if (a.length != b.length) { + throw new ScramException("XOR operands must have equal length: " + a.length + " vs " + b.length); + } + for (int i = 0; i < a.length; i++) { + a[i] ^= b[i]; + } + return a; + } + + /** + * Generate a cryptographically random nonce, base64-encoded. + * + * @param lengthBytes number of random bytes (before base64 encoding) + * @return base64-encoded nonce string + */ + public static String generateNonce(int lengthBytes) { + byte[] bytes = new byte[lengthBytes]; + SECURE_RANDOM.get().nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } + + /** + * Normalize password per RFC 7613 OpaqueString profile. + * Steps applied in order: 1) width mapping (preserve), 2) non-ASCII spaces to U+0020, + * 3) case preserved, 4) NFC normalization, 5) prohibited char check. + */ + public static String normalizePassword(String password) { + if (password == null || password.isEmpty()) { + return password; + } + + // Steps 1-2: Width mapping (preserve) + map non-ASCII spaces to U+0020 + StringBuilder sb = new StringBuilder(password.length()); + for (int i = 0; i < password.length(); ) { + int cp = password.codePointAt(i); + + // Step 2: Map non-ASCII spaces (Unicode Zs category except U+0020) to U+0020 + if (cp != 0x0020 && Character.getType(cp) == Character.SPACE_SEPARATOR) { + sb.append(' '); + } else { + sb.appendCodePoint(cp); + } + + i += Character.charCount(cp); + } + + // Step 3: Case preserved (no-op) + + // Step 4: Apply NFC normalization + String normalized = Normalizer.normalize(sb, Normalizer.Form.NFC); + + // Step 5: Reject prohibited characters (PRECIS FreeformClass) — after NFC + for (int i = 0; i < normalized.length(); ) { + int cp = normalized.codePointAt(i); + if (isProhibited(cp)) { + throw new ScramException("Password contains prohibited character: U+" + String.format("%04X", cp)); + } + i += Character.charCount(cp); + } + + return normalized; + } + + /** + * Check if a codepoint is prohibited by PRECIS FreeformClass (RFC 8264 §9.11). + */ + private static boolean isProhibited(int cp) { + // Control characters (except HT, LF, CR) + if (cp <= 0x001F && cp != 0x0009 && cp != 0x000A && cp != 0x000D) { + return true; + } + if (cp == 0x007F) { + return true; // DEL + } + if (cp >= 0x0080 && cp <= 0x009F) { + return true; // C1 control characters + } + + // Surrogates (should not appear in valid Java strings, but check anyway) + if (cp >= 0xD800 && cp <= 0xDFFF) { + return true; + } + + // Non-characters + if (cp >= 0xFDD0 && cp <= 0xFDEF) { + return true; + } + if ((cp & 0xFFFE) == 0xFFFE && cp <= 0x10FFFF) { + return true; // U+xFFFE and U+xFFFF for any plane + } + + // Unassigned codepoints + int type = Character.getType(cp); + if (type == Character.UNASSIGNED) { + return true; + } + + return false; + } + + /** + * SaltedPassword := Hi(Normalize(password), salt, i) + */ + public static byte[] computeSaltedPassword(String password, byte[] salt, int iterations) { + String normalized = normalizePassword(password); + return hi(normalized, salt, iterations); + } + + /** + * ClientKey := HMAC(SaltedPassword, "Client Key") + */ + public static byte[] computeClientKey(byte[] saltedPassword) { + return hmac(saltedPassword, "Client Key".getBytes(StandardCharsets.US_ASCII)); + } + + /** + * StoredKey := H(ClientKey) + */ + public static byte[] computeStoredKey(byte[] clientKey) { + return hash(clientKey); + } + + /** + * ServerKey := HMAC(SaltedPassword, "Server Key") + */ + public static byte[] computeServerKey(byte[] saltedPassword) { + return hmac(saltedPassword, "Server Key".getBytes(StandardCharsets.US_ASCII)); + } + + /** + * ClientSignature := HMAC(StoredKey, AuthMessage) + */ + public static byte[] computeClientSignature(byte[] storedKey, String authMessage) { + return hmac(storedKey, authMessage.getBytes(StandardCharsets.UTF_8)); + } + + /** + * ClientProof := ClientKey XOR ClientSignature + */ + public static byte[] computeClientProof(byte[] clientKey, byte[] clientSignature) { + return xor(clientKey.clone(), clientSignature); + } + + /** + * ServerSignature := HMAC(ServerKey, AuthMessage) + */ + public static byte[] computeServerSignature(byte[] serverKey, String authMessage) { + return hmac(serverKey, authMessage.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Zero out a byte array for security. + */ + public static void zeroBytes(byte[] array) { + if (array != null) { + Arrays.fill(array, (byte) 0); + } + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramException.java b/client/src/main/java/org/asynchttpclient/scram/ScramException.java new file mode 100644 index 0000000000..dd9fd818bc --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramException.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +/** + * Exception thrown when a SCRAM authentication error occurs. + */ +public class ScramException extends RuntimeException { + + public ScramException(String message) { + super(message); + } + + public ScramException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java b/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java new file mode 100644 index 0000000000..0b671067f0 --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.jetbrains.annotations.Nullable; + +import java.util.Base64; + +/** + * Formats SCRAM protocol messages (RFC 5802) and HTTP headers (RFC 7804). + * Thread-safe: all methods are stateless. + */ +public final class ScramMessageFormatter { + + private ScramMessageFormatter() { + } + + /** + * Escape a username per RFC 5802: "=" → "=3D", "," → "=2C". + */ + public static String escapeUsername(String username) { + // Order matters: escape '=' first to avoid double-escaping + return username.replace("=", "=3D").replace(",", "=2C"); + } + + /** + * Format the bare portion of client-first-message (without gs2-header). + * "n=<escaped-user>,r=<c-nonce>" + */ + public static String clientFirstMessageBare(String username, String clientNonce) { + return "n=" + escapeUsername(username) + ",r=" + clientNonce; + } + + /** + * Format the full client-first-message including gs2-header. + * "n,,n=<escaped-user>,r=<c-nonce>" + */ + public static String formatClientFirstMessage(String username, String clientNonce) { + return "n,," + clientFirstMessageBare(username, clientNonce); + } + + /** + * Format client-final-message-without-proof. + * "c=biws,r=<full-nonce>" + */ + public static String clientFinalMessageWithoutProof(String fullNonce) { + return "c=biws,r=" + fullNonce; + } + + /** + * Format the full client-final-message with proof. + * "c=biws,r=<full-nonce>,p=<base64-proof>" + */ + public static String formatClientFinalMessage(String fullNonce, byte[] clientProof) { + return clientFinalMessageWithoutProof(fullNonce) + ",p=" + Base64.getEncoder().encodeToString(clientProof); + } + + /** + * Format the HTTP Authorization header value for SCRAM. + * Per Erratum 6558, the data attribute is quoted. + * + * @param mechanism "SCRAM-SHA-256" + * @param realm realm value (may be null) + * @param sid session ID (may be null) + * @param base64Data base64-encoded SCRAM message + * @return formatted header value + */ + public static String formatAuthorizationHeader(String mechanism, @Nullable String realm, + @Nullable String sid, String base64Data) { + StringBuilder sb = new StringBuilder(mechanism); + if (realm != null) { + sb.append(" realm=\"").append(realm).append("\","); + } + if (sid != null) { + sb.append(" sid=").append(sid).append(","); + } + sb.append(" data=\"").append(base64Data).append("\""); + return sb.toString(); + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java new file mode 100644 index 0000000000..8cb0a2fa9a --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.asynchttpclient.Realm; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Base64; + +/** + * Parser for SCRAM protocol messages (RFC 5802) and HTTP authentication headers (RFC 7804). + * Thread-safe: all methods are stateless. + */ +public final class ScramMessageParser { + + private static final Logger LOGGER = LoggerFactory.getLogger(ScramMessageParser.class); + + private ScramMessageParser() { + } + + /** + * Parsed server-first-message fields. + */ + public static class ServerFirstMessage { + public final String fullNonce; // r= value (client-nonce + server-nonce) + public final byte[] salt; // s= value (base64-decoded) + public final int iterationCount; // i= value + + public ServerFirstMessage(String fullNonce, byte[] salt, int iterationCount) { + this.fullNonce = fullNonce; + this.salt = salt; + this.iterationCount = iterationCount; + } + } + + /** + * Parsed server-final-message fields. + */ + public static class ServerFinalMessage { + public final @Nullable String verifier; // v= value (base64 encoded ServerSignature) + public final @Nullable String error; // e= value + + public ServerFinalMessage(@Nullable String verifier, @Nullable String error) { + this.verifier = verifier; + this.error = error; + } + } + + /** + * Parsed SCRAM HTTP challenge/response parameters from WWW-Authenticate header. + */ + public static class ScramChallengeParams { + public final @Nullable String realm; + public final @Nullable String data; // base64 encoded SCRAM message + public final @Nullable String sid; + public final @Nullable String sr; // server nonce for reauthentication + public final int ttl; // -1 if absent + public final boolean stale; + + public ScramChallengeParams(@Nullable String realm, @Nullable String data, + @Nullable String sid, @Nullable String sr, + int ttl, boolean stale) { + this.realm = realm; + this.data = data; + this.sid = sid; + this.sr = sr; + this.ttl = ttl; + this.stale = stale; + } + } + + /** + * Parse a server-first-message (RFC 5802): r=,s=,i=[,extensions] + */ + public static ServerFirstMessage parseServerFirst(String message) { + String fullNonce = null; + String saltBase64 = null; + int iterationCount = -1; + + String[] parts = message.split(","); + for (String part : parts) { + if (part.startsWith("r=")) { + fullNonce = part.substring(2); + } else if (part.startsWith("s=")) { + saltBase64 = part.substring(2); + } else if (part.startsWith("i=")) { + try { + iterationCount = Integer.parseInt(part.substring(2)); + } catch (NumberFormatException e) { + throw new ScramException("Invalid iteration count in server-first-message: " + part.substring(2)); + } + } + // Extensions after i= are tolerated per RFC 5802 + } + + if (fullNonce == null) { + throw new ScramException("Missing nonce (r=) in server-first-message"); + } + if (saltBase64 == null || saltBase64.isEmpty()) { + throw new ScramException("Missing or empty salt (s=) in server-first-message"); + } + + byte[] salt; + try { + salt = Base64.getDecoder().decode(saltBase64); + } catch (IllegalArgumentException e) { + throw new ScramException("Invalid base64 salt in server-first-message", e); + } + if (salt.length == 0) { + throw new ScramException("Empty salt in server-first-message"); + } + + if (iterationCount < 1) { + throw new ScramException("Invalid iteration count: " + iterationCount); + } + + return new ServerFirstMessage(fullNonce, salt, iterationCount); + } + + /** + * Parse a server-final-message (RFC 5802): v= OR e= + */ + public static ServerFinalMessage parseServerFinal(String message) { + if (message.startsWith("v=")) { + return new ServerFinalMessage(message.substring(2), null); + } else if (message.startsWith("e=")) { + return new ServerFinalMessage(null, message.substring(2)); + } else { + throw new ScramException("Invalid server-final-message: must start with v= or e="); + } + } + + /** + * Parse SCRAM-specific parameters from a WWW-Authenticate or Authentication-Info header value. + * The header value should have the SCRAM-SHA-xxx prefix already stripped (or the full header). + */ + public static ScramChallengeParams parseWwwAuthenticateScram(String headerValue) { + String realm = Realm.Builder.matchParam(headerValue, "realm"); + String data = Realm.Builder.matchParam(headerValue, "data"); + String sid = Realm.Builder.matchParam(headerValue, "sid"); + if (sid == null) { + // sid may be unquoted token + sid = matchUnquotedToken(headerValue, "sid"); + } + String sr = Realm.Builder.matchParam(headerValue, "sr"); + if (sr == null) { + sr = matchUnquotedToken(headerValue, "sr"); + } + + int ttl = -1; + String ttlStr = Realm.Builder.matchParam(headerValue, "ttl"); + if (ttlStr == null) { + ttlStr = matchUnquotedToken(headerValue, "ttl"); + } + if (ttlStr != null) { + try { + ttl = Integer.parseInt(ttlStr); + } catch (NumberFormatException e) { + LOGGER.warn("SCRAM: invalid ttl value: {}", ttlStr); + } + } + + String staleStr = Realm.Builder.matchParam(headerValue, "stale"); + if (staleStr == null) { + staleStr = matchUnquotedToken(headerValue, "stale"); + } + boolean stale = "true".equalsIgnoreCase(staleStr); + + // Check for duplicate realm (RFC 7804 §5: MUST NOT appear more than once) + checkDuplicateRealm(headerValue); + + return new ScramChallengeParams(realm, data, sid, sr, ttl, stale); + } + + /** + * Match an unquoted token value in a header (e.g., sid=AAAABBBB). + */ + private static @Nullable String matchUnquotedToken(String headerLine, String token) { + String prefix = token + "="; + int idx = headerLine.indexOf(prefix); + while (idx >= 0) { + // Verify the character before is a boundary + if (idx == 0 || headerLine.charAt(idx - 1) == ' ' || headerLine.charAt(idx - 1) == ',') { + int valStart = idx + prefix.length(); + if (valStart < headerLine.length() && headerLine.charAt(valStart) != '"') { + int valEnd = valStart; + while (valEnd < headerLine.length() && headerLine.charAt(valEnd) != ',' && headerLine.charAt(valEnd) != ' ') { + valEnd++; + } + if (valEnd > valStart) { + return headerLine.substring(valStart, valEnd); + } + } + } + idx = headerLine.indexOf(prefix, idx + 1); + } + return null; + } + + private static void checkDuplicateRealm(String headerValue) { + int first = indexOfParam(headerValue, "realm"); + if (first >= 0) { + int second = indexOfParam(headerValue, "realm", first + 1); + if (second >= 0) { + LOGGER.warn("SCRAM: duplicate realm attribute detected (RFC 7804 §5: MUST NOT appear more than once)"); + } + } + } + + private static int indexOfParam(String headerValue, String param) { + return indexOfParam(headerValue, param, 0); + } + + private static int indexOfParam(String headerValue, String param, int fromIndex) { + String search = param + "="; + int idx = headerValue.indexOf(search, fromIndex); + while (idx >= 0) { + if (idx == 0 || headerValue.charAt(idx - 1) == ' ' || headerValue.charAt(idx - 1) == ',') { + return idx; + } + idx = headerValue.indexOf(search, idx + 1); + } + return -1; + } + + /** + * Validate that the full nonce starts with the client nonce. + */ + public static void validateNoncePrefix(String clientNonce, String fullNonce) { + if (!fullNonce.startsWith(clientNonce)) { + throw new ScramException("Server nonce does not contain client nonce prefix"); + } + if (fullNonce.length() <= clientNonce.length()) { + throw new ScramException("Server nonce part is empty"); + } + } + + /** + * Validate the gs2-header starts with "n" (no channel binding). + */ + public static void validateGs2Header(String message) { + if (!message.startsWith("n,")) { + throw new ScramException("Invalid gs2-header: channel binding not supported, must start with 'n'"); + } + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java new file mode 100644 index 0000000000..aa5a02f341 --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Thread-safe cache for SCRAM reauthentication data (RFC 7804 §5.1). + * Keyed by (host, port, realm). Stores derived keys (NOT SaltedPassword). + */ +public class ScramSessionCache { + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final int maxEntries; + + public ScramSessionCache() { + this(1000); + } + + public ScramSessionCache(int maxEntries) { + this.maxEntries = maxEntries; + } + + /** + * Cache key: (host, port, realm). + */ + public static class CacheKey { + private final String host; + private final int port; + private final @Nullable String realm; + + public CacheKey(String host, int port, @Nullable String realm) { + this.host = host; + this.port = port; + this.realm = realm; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CacheKey)) return false; + CacheKey that = (CacheKey) o; + return port == that.port && host.equals(that.host) && Objects.equals(realm, that.realm); + } + + @Override + public int hashCode() { + return Objects.hash(host, port, realm); + } + } + + /** + * Cached session entry for reauthentication. + * SECURITY: No SaltedPassword stored — only derived keys. + */ + public static class Entry { + public final @Nullable String realmName; + public final byte[] salt; + public final int iterationCount; + public final byte[] clientKey; + public final byte[] storedKey; + public final byte[] serverKey; + public volatile @Nullable String sr; + public volatile int ttl; // -1 = no expiration + public volatile long srTimestampNanos; + public final AtomicInteger nonceCount; + public final String originalServerFirstMessage; + + public Entry(@Nullable String realmName, byte[] salt, int iterationCount, + byte[] clientKey, byte[] storedKey, byte[] serverKey, + @Nullable String sr, int ttl, long srTimestampNanos, + int initialNonceCount, String originalServerFirstMessage) { + this.realmName = realmName; + this.salt = salt; + this.iterationCount = iterationCount; + this.clientKey = clientKey; + this.storedKey = storedKey; + this.serverKey = serverKey; + this.sr = sr; + this.ttl = ttl; + this.srTimestampNanos = srTimestampNanos; + this.nonceCount = new AtomicInteger(initialNonceCount); + this.originalServerFirstMessage = originalServerFirstMessage; + } + } + + public void put(CacheKey key, Entry entry) { + // Size-bounded eviction: remove oldest entry by sr timestamp + if (cache.size() >= maxEntries) { + CacheKey oldest = null; + long oldestTimestamp = Long.MAX_VALUE; + for (Map.Entry e : cache.entrySet()) { + if (e.getValue().srTimestampNanos < oldestTimestamp) { + oldestTimestamp = e.getValue().srTimestampNanos; + oldest = e.getKey(); + } + } + if (oldest != null) { + cache.remove(oldest); + } + } + cache.put(key, entry); + } + + public @Nullable Entry get(CacheKey key) { + return cache.get(key); + } + + /** + * Update the server nonce (sr) after a stale response. + */ + public void updateSr(CacheKey key, String newSr, int newTtl) { + Entry entry = cache.get(key); + if (entry != null) { + entry.sr = newSr; + entry.ttl = newTtl; + entry.srTimestampNanos = System.nanoTime(); + } + } + + /** + * Check if the sr value is still fresh. + * If ttl == -1: always fresh (no expiration). + * If ttl >= 0: check elapsed time since sr was received. + */ + public boolean isSrFresh(Entry entry) { + if (entry.sr == null) { + return false; + } + if (entry.ttl < 0) { + return true; // No expiration — rely on server stale=true + } + long elapsedNanos = System.nanoTime() - entry.srTimestampNanos; + long ttlNanos = (long) entry.ttl * 1_000_000_000L; + return elapsedNanos < ttlNanos; + } + + /** + * Atomically reserve and increment the nonce-count for use in a request. + * Call rollbackNonceCount on failure to undo. + */ + public int reserveNonceCount(CacheKey key) { + Entry entry = cache.get(key); + if (entry == null) { + return -1; + } + return entry.nonceCount.getAndIncrement(); + } + + /** + * Confirm nonce-count after successful reauthentication. No-op since already incremented. + */ + public void confirmNonceCount(CacheKey key) { + // No-op: nonce-count was already atomically incremented in reserveNonceCount + } + + /** + * Rollback nonce-count on failure or stale response by decrementing. + */ + public void rollbackNonceCount(CacheKey key) { + Entry entry = cache.get(key); + if (entry != null) { + entry.nonceCount.decrementAndGet(); + } + } + + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } +} diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramState.java b/client/src/main/java/org/asynchttpclient/scram/ScramState.java new file mode 100644 index 0000000000..eb81b11e0c --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/scram/ScramState.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +/** + * States for a SCRAM authentication exchange (RFC 7804). + */ +public enum ScramState { + INITIAL, + CLIENT_FIRST_SENT, + SERVER_FIRST_RECEIVED, + CLIENT_FINAL_SENT, + AUTHENTICATED, + FAILED +} diff --git a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java index fb3a77a83f..444ef85032 100644 --- a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java @@ -511,6 +511,9 @@ private static void append(StringBuilder builder, String name, @Nullable String proxyAuthorization = "NTLM " + msg; } + break; + case SCRAM_SHA_256: + // SCRAM auth is per-request, not per-connection break; default: } @@ -550,6 +553,9 @@ private static void append(StringBuilder builder, String name, @Nullable String // NTLM, KERBEROS and SPNEGO are only set on the first request with a connection, // see perConnectionProxyAuthorizationHeader break; + case SCRAM_SHA_256: + // SCRAM reauthentication for proxy — handled via interceptor + break; default: throw new IllegalStateException("Invalid Authentication scheme " + proxyRealm.getScheme()); } @@ -592,6 +598,9 @@ private static void append(StringBuilder builder, String name, @Nullable String throw new RuntimeException(e); } break; + case SCRAM_SHA_256: + // SCRAM auth is per-request, not per-connection + break; default: break; } @@ -630,6 +639,9 @@ private static void append(StringBuilder builder, String name, @Nullable String // NTLM, KERBEROS and SPNEGO are only set on the first request with a connection, // see perConnectionAuthorizationHeader break; + case SCRAM_SHA_256: + // SCRAM reauthentication — handled via interceptor + break; default: throw new IllegalStateException("Invalid Authentication " + realm); } diff --git a/client/src/test/java/org/asynchttpclient/ScramAuthTest.java b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java new file mode 100644 index 0000000000..275c7fb7a0 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient; + +import io.github.artsok.RepeatedIfExceptionsTest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.asynchttpclient.scram.ScramEngine; +import org.asynchttpclient.scram.ScramMessageParser; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.BeforeEach; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.scramSha256AuthRealm; +import static org.asynchttpclient.test.TestUtils.ADMIN; +import static org.asynchttpclient.test.TestUtils.USER; +import static org.asynchttpclient.test.TestUtils.addHttpConnector; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class ScramAuthTest extends AbstractBasicTest { + + @Override + @BeforeEach + public void setUpGlobal() throws Exception { + server = new Server(); + ServerConnector connector = addHttpConnector(server); + server.setHandler(new ScramAuthHandler()); + server.start(); + port1 = connector.getLocalPort(); + logger.info("Local HTTP server started successfully"); + } + + @Override + public AbstractHandler configureHandler() throws Exception { + return new ScramAuthHandler(); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_fullExchange() throws Exception { + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertNotNull(resp.getHeader("X-Auth")); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_wrongPassword() throws Exception { + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, "wrongpassword").setRealmName("ScramRealm").build()) + .execute(); + Response resp = f.get(20, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(401, resp.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_maxIterationCount() throws Exception { + // Server uses 4096 iterations, client max is 100 — should fail + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, ADMIN) + .setRealmName("ScramRealm") + .setMaxIterationCount(100) // Lower than server's 4096 + .build()) + .execute(); + Response resp = f.get(20, TimeUnit.SECONDS); + assertNotNull(resp); + // Should get 401 because client aborts the exchange + assertEquals(401, resp.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_missingAuthInfo() throws Exception { + // Test with handler that doesn't send Authentication-Info + server.stop(); + server = new Server(); + ServerConnector connector = addHttpConnector(server); + server.setHandler(new ScramAuthHandler(false)); // no auth-info + server.start(); + port1 = connector.getLocalPort(); + + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + // 200 without Authentication-Info — warning logged, but request succeeds + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_quotedDataAttribute() throws Exception { + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + // The handler validates that data is properly quoted (Erratum 6558) + assertEquals("quoted", resp.getHeader("X-Data-Quoted")); + } + } + + /** + * Server-side SCRAM-SHA-256 authenticator for testing. + * Implements the full 3-step SCRAM handshake: + * 1. Initial 401 with SCRAM challenge + * 2. Second 401 with server-first-message + * 3. 200 with Authentication-Info (server-final-message) + */ + private static class ScramAuthHandler extends AbstractHandler { + + private static final String REALM = "ScramRealm"; + private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="); + private static final int ITERATIONS = 4096; + + private final boolean sendAuthInfo; + + // Session tracking: sid → ServerSession + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + ScramAuthHandler() { + this(true); + } + + ScramAuthHandler(boolean sendAuthInfo) { + this.sendAuthInfo = sendAuthInfo; + } + + static class ServerSession { + final String fullNonce; + final String clientFirstBare; + + ServerSession(String clientNonce, String serverNoncePart, String clientFirstBare) { + this.fullNonce = clientNonce + serverNoncePart; + this.clientFirstBare = clientFirstBare; + } + } + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException { + String authz = request.getHeader("Authorization"); + + if (authz == null) { + // Step 1: Send initial challenge + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 realm=\"" + REALM + "\""); + response.getOutputStream().close(); + return; + } + + if (!authz.startsWith("SCRAM-SHA-256")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz); + + // Check if data attribute was properly quoted (Erratum 6558) + boolean dataQuoted = authz.contains("data=\""); + response.setHeader("X-Data-Quoted", dataQuoted ? "quoted" : "unquoted"); + + if (params.sid == null && params.data != null) { + // Step 2: Process client-first, send server-first + String clientFirstFull = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + + // Validate gs2-header + if (!clientFirstFull.startsWith("n,,")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + // Parse client-first-message + String clientFirstBare = clientFirstFull.substring(3); // Remove "n,," + String clientNonce = null; + String clientUsername = null; + for (String part : clientFirstBare.split(",")) { + if (part.startsWith("r=")) { + clientNonce = part.substring(2); + } else if (part.startsWith("n=")) { + clientUsername = part.substring(2); + } + } + + if (clientNonce == null || clientUsername == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getOutputStream().close(); + return; + } + + // Verify username + if (!USER.equals(clientUsername.replace("=3D", "=").replace("=2C", ","))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + // Generate server nonce and sid + String serverNoncePart = ScramEngine.generateNonce(18); + String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + + // Store session + sessions.put(sid, new ServerSession(clientNonce, serverNoncePart, clientFirstBare)); + + // Build server-first-message + String fullNonce = clientNonce + serverNoncePart; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + String base64ServerFirst = Base64.getEncoder().encodeToString(serverFirstMsg.getBytes(StandardCharsets.UTF_8)); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 sid=" + sid + ", data=\"" + base64ServerFirst + "\""); + response.getOutputStream().close(); + + } else if (params.sid != null && params.data != null) { + // Step 3: Process client-final, validate proof, send server-final + ServerSession session = sessions.remove(params.sid); + if (session == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + String clientFinalMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + + // Parse client-final + String clientFinalNonce = null; + String clientProofBase64 = null; + for (String part : clientFinalMsg.split(",")) { + if (part.startsWith("r=")) { + clientFinalNonce = part.substring(2); + } else if (part.startsWith("p=")) { + clientProofBase64 = part.substring(2); + } + } + + if (clientFinalNonce == null || clientProofBase64 == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getOutputStream().close(); + return; + } + + // Validate nonce + if (!clientFinalNonce.equals(session.fullNonce)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + // Compute server-side values to verify client proof + byte[] saltedPassword = ScramEngine.computeSaltedPassword(ADMIN, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + + // Build AuthMessage + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + session.fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + String clientFinalWithoutProof = "c=biws,r=" + session.fullNonce; + String authMessage = session.clientFirstBare + "," + serverFirstMsg + "," + clientFinalWithoutProof; + + // Verify client proof + byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage); + byte[] receivedProof = Base64.getDecoder().decode(clientProofBase64); + byte[] recoveredClientKey = ScramEngine.xor(receivedProof.clone(), clientSignature); + + // StoredKey = H(ClientKey) — verify + byte[] recoveredStoredKey = ScramEngine.hash(recoveredClientKey); + boolean proofValid = Arrays.equals(storedKey, recoveredStoredKey); + + ScramEngine.zeroBytes(saltedPassword); + + if (!proofValid) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + // Compute ServerSignature for Authentication-Info + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature); + String base64ServerFinal = Base64.getEncoder().encodeToString(serverFinal.getBytes(StandardCharsets.UTF_8)); + + response.setStatus(HttpServletResponse.SC_OK); + response.addHeader("X-Auth", authz); + if (sendAuthInfo) { + response.setHeader("Authentication-Info", "sid=" + params.sid + ", data=\"" + base64ServerFinal + "\""); + } + response.getOutputStream().flush(); + response.getOutputStream().close(); + + } else { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getOutputStream().close(); + } + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java new file mode 100644 index 0000000000..feacd19cca --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class ScramContextTest { + + private static final String USERNAME = "user"; + private static final String PASSWORD = "pencil"; + private static final String REALM = "testrealm@example.com"; + private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="); + private static final int ITERATIONS = 4096; + private static final int MAX_ITERATIONS = 16_384; + + @Test + void testFullExchange_SHA256() { + // Step 1: Client-first + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + + assertEquals(ScramState.CLIENT_FIRST_SENT, ctx.getState()); + assertEquals("SCRAM-SHA-256", ctx.getMechanism()); + assertEquals(USERNAME, ctx.getUsername()); + assertEquals(REALM, ctx.getRealmName()); + + // Verify client-first-message format + String clientFirst = ctx.getClientFirstMessage(); + assertTrue(clientFirst.startsWith("n,,n=user,r=")); + String clientFirstBare = ctx.getClientFirstMessageBare(); + assertTrue(clientFirstBare.startsWith("n=user,r=")); + assertEquals("n,," + clientFirstBare, clientFirst); + + // Step 2: Server-first — build a valid server-first-message + String clientNonce = ctx.getClientNonce(); + String serverNoncePart = "srvr%hvYDpWUa2RaTCAfuxFIlj)hNlF"; + String fullNonce = clientNonce + serverNoncePart; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + + ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS); + + assertEquals(ScramState.SERVER_FIRST_RECEIVED, ctx.getState()); + assertEquals(fullNonce, ctx.getServerNonce()); + assertNotNull(ctx.getClientKey()); + assertNotNull(ctx.getStoredKey()); + assertNotNull(ctx.getServerKey()); + + // Step 3: Client-final + String clientFinal = ctx.computeClientFinal(); + assertEquals(ScramState.CLIENT_FINAL_SENT, ctx.getState()); + assertTrue(clientFinal.startsWith("c=biws,r=" + fullNonce + ",p=")); + + // Step 4: Verify server-final + // Compute expected ServerSignature + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + String authMessage = clientFirstBare + "," + serverFirstMsg + "," + + ScramMessageFormatter.clientFinalMessageWithoutProof(fullNonce); + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature); + + assertTrue(ctx.verifyServerFinal(serverFinal)); + assertEquals(ScramState.AUTHENTICATED, ctx.getState()); + + ScramEngine.zeroBytes(saltedPassword); + } + + @Test + void testProcessServerFirst_iterationCountTooHigh() { + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + String clientNonce = ctx.getClientNonce(); + String serverFirstMsg = "r=" + clientNonce + "srvr,s=c2FsdA==,i=100000"; + + assertThrows(ScramException.class, () -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS)); + } + + @Test + void testProcessServerFirst_nonceNotPrefixed() { + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + // Server nonce doesn't start with client nonce + String serverFirstMsg = "r=wrongPrefix,s=c2FsdA==,i=4096"; + + assertThrows(ScramException.class, () -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS)); + } + + @Test + void testVerifyServerFinal_valid() { + ScramContext ctx = createAuthenticatedContext(); + // Already verified in full exchange test — just verify state + assertEquals(ScramState.AUTHENTICATED, ctx.getState()); + } + + @Test + void testVerifyServerFinal_invalid() { + ScramContext ctx = createContextAtClientFinalSent(); + // Wrong server signature + assertFalse(ctx.verifyServerFinal("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")); + assertEquals(ScramState.FAILED, ctx.getState()); + } + + @Test + void testVerifyServerFinal_serverError() { + ScramContext ctx = createContextAtClientFinalSent(); + assertFalse(ctx.verifyServerFinal("e=invalid-proof")); + assertEquals(ScramState.FAILED, ctx.getState()); + } + + @Test + void testGetClientFirstMessage_includesGs2Header() { + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + assertTrue(ctx.getClientFirstMessage().startsWith("n,,")); + } + + @Test + void testToSessionCacheEntry() { + ScramContext ctx = createAuthenticatedContext(); + ScramSessionCache.Entry entry = ctx.toSessionCacheEntry("serverNonce123", 300); + assertEquals(REALM, entry.realmName); + assertNotNull(entry.clientKey); + assertNotNull(entry.storedKey); + assertNotNull(entry.serverKey); + assertEquals("serverNonce123", entry.sr); + assertEquals(300, entry.ttl); + assertEquals(ITERATIONS, entry.nonceCount.get()); + assertNotNull(entry.originalServerFirstMessage); + } + + // Helper methods + + private ScramContext createContextAtClientFinalSent() { + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + String clientNonce = ctx.getClientNonce(); + String fullNonce = clientNonce + "serverpart"; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS); + ctx.computeClientFinal(); + return ctx; + } + + private ScramContext createAuthenticatedContext() { + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + String clientNonce = ctx.getClientNonce(); + String fullNonce = clientNonce + "serverpart"; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + + ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS); + ctx.computeClientFinal(); + + // Compute correct server signature + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + String authMessage = ctx.getClientFirstMessageBare() + "," + serverFirstMsg + "," + + ctx.getClientFinalMessageWithoutProof(); + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature); + ctx.verifyServerFinal(serverFinal); + ScramEngine.zeroBytes(saltedPassword); + return ctx; + } +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java new file mode 100644 index 0000000000..c2cf84ea3e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class ScramEngineTest { + + // RFC 5802 test vector parameters + private static final String PASSWORD = "pencil"; + private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="); + private static final int ITERATIONS = 4096; + + @Test + void testHi_SHA256_RFC5802Vector() { + // Verify PBKDF2 / Hi() with SHA-256 produces expected output for "pencil" with known salt + byte[] result = ScramEngine.hi(PASSWORD, SALT, ITERATIONS); + assertNotNull(result); + assertEquals(32, result.length); // SHA-256 produces 32 bytes + } + + @Test + void testHmac_SHA256() { + byte[] key = "test-key".getBytes(StandardCharsets.US_ASCII); + byte[] data = "test-data".getBytes(StandardCharsets.US_ASCII); + byte[] result = ScramEngine.hmac(key, data); + assertNotNull(result); + assertEquals(32, result.length); + } + + @Test + void testXor_equalLength() { + byte[] a = {0x01, 0x02, 0x03, 0x04}; + byte[] b = {0x05, 0x06, 0x07, 0x08}; + byte[] result = ScramEngine.xor(a, b); + assertArrayEquals(new byte[]{0x04, 0x04, 0x04, 0x0C}, result); + // xor mutates first array in-place + assertSame(a, result); + } + + @Test + void testXor_unequalLength_throws() { + byte[] a = {0x01, 0x02}; + byte[] b = {0x01, 0x02, 0x03}; + assertThrows(ScramException.class, () -> ScramEngine.xor(a, b)); + } + + @Test + void testComputeSaltedPassword() { + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + assertNotNull(saltedPassword); + assertEquals(32, saltedPassword.length); + } + + @Test + void testComputeClientKey() { + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + assertNotNull(clientKey); + assertEquals(32, clientKey.length); + } + + @Test + void testComputeStoredKey() { + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + assertNotNull(storedKey); + assertEquals(32, storedKey.length); + } + + @Test + void testComputeServerKey() { + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + assertNotNull(serverKey); + assertEquals(32, serverKey.length); + } + + @Test + void testComputeClientProof() { + byte[] clientKey = {0x01, 0x02, 0x03, 0x04}; + byte[] clientSignature = {0x05, 0x06, 0x07, 0x08}; + byte[] proof = ScramEngine.computeClientProof(clientKey, clientSignature); + // XOR of {01,02,03,04} and {05,06,07,08} = {04,04,04,0C} + assertArrayEquals(new byte[]{0x04, 0x04, 0x04, 0x0C}, proof); + // Original clientKey should NOT be modified (clone is used) + assertArrayEquals(new byte[]{0x01, 0x02, 0x03, 0x04}, clientKey); + } + + @Test + void testComputeServerSignature() { + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + String authMessage = "n=user,r=rOprNGfwEbeRWgbNEkqO,r=rOprNGfwEbeRWgbNEkqOsrvr,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,c=biws,r=rOprNGfwEbeRWgbNEkqOsrvr"; + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + assertNotNull(serverSignature); + assertEquals(32, serverSignature.length); + } + + @Test + void testNormalizePassword_ascii() { + assertEquals("pencil", ScramEngine.normalizePassword("pencil")); + assertEquals("hello world", ScramEngine.normalizePassword("hello world")); + } + + @Test + void testNormalizePassword_nonAscii_NFC() { + // U+00E9 (é) is already NFC, should pass through + assertEquals("\u00E9", ScramEngine.normalizePassword("\u00E9")); + // U+0065 U+0301 (e + combining accent) should be normalized to U+00E9 + assertEquals("\u00E9", ScramEngine.normalizePassword("e\u0301")); + } + + @Test + void testNormalizePassword_nonAsciiSpaces() { + // U+00A0 (non-breaking space) → U+0020 + assertEquals("hello world", ScramEngine.normalizePassword("hello\u00A0world")); + } + + @Test + void testNormalizePassword_controlChars_rejected() { + // U+0000 (NUL) should be rejected + assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u0000word")); + // U+0001 (SOH) should be rejected + assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u0001word")); + // U+007F (DEL) should be rejected + assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u007Fword")); + } + + @Test + void testNormalizePassword_tabAndNewlineAllowed() { + // HT (U+0009), LF (U+000A), CR (U+000D) are allowed + assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\tword")); + assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\nword")); + assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\rword")); + } + + @Test + void testNormalizePassword_vulgarFraction() { + // U+00BD (½) - NFC preserves it (unlike NFKC which would decompose to "1/2") + String result = ScramEngine.normalizePassword("\u00BD"); + assertEquals("\u00BD", result); + } + + @Test + void testNormalizePassword_widthPreserved() { + // U+FF21 (fullwidth 'A') - OpaqueString preserves fullwidth chars + String result = ScramEngine.normalizePassword("\uFF21"); + assertEquals("\uFF21", result); + } + + @Test + void testZeroBytes() { + byte[] data = {1, 2, 3, 4, 5}; + ScramEngine.zeroBytes(data); + assertArrayEquals(new byte[]{0, 0, 0, 0, 0}, data); + } + + @Test + void testZeroBytes_null() { + // Should not throw + assertDoesNotThrow(() -> ScramEngine.zeroBytes(null)); + } + + @Test + void testGenerateNonce() { + String nonce1 = ScramEngine.generateNonce(24); + String nonce2 = ScramEngine.generateNonce(24); + assertNotNull(nonce1); + assertNotNull(nonce2); + assertNotEquals(nonce1, nonce2); // cryptographically random should differ + // 24 bytes base64 = 32 chars + assertEquals(32, nonce1.length()); + } + + @Test + void testHi_passwordIsString() { + // Verify Hi accepts String and UTF-8 encodes internally + byte[] result = ScramEngine.hi("p\u00E4ssw\u00F6rd", SALT, ITERATIONS); + assertNotNull(result); + assertEquals(32, result.length); + } + + @Test + void testHash_SHA256() { + byte[] data = "test".getBytes(StandardCharsets.US_ASCII); + byte[] hash = ScramEngine.hash(data); + assertEquals(32, hash.length); + } + + @Test + void testFullKeyDerivation_SHA256() { + // Verify the full key derivation chain works end-to-end + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + + // All should be 32 bytes (SHA-256) + assertEquals(32, saltedPassword.length); + assertEquals(32, clientKey.length); + assertEquals(32, storedKey.length); + assertEquals(32, serverKey.length); + + // StoredKey = H(ClientKey) should be deterministic + byte[] storedKey2 = ScramEngine.hash(clientKey); + assertArrayEquals(storedKey, storedKey2); + + // Clean up + ScramEngine.zeroBytes(saltedPassword); + } +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java new file mode 100644 index 0000000000..67b468181d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class ScramMessageFormatterTest { + + @Test + void testFormatClientFirst() { + String result = ScramMessageFormatter.formatClientFirstMessage("user", "rOprNGfwEbeRWgbNEkqO"); + assertEquals("n,,n=user,r=rOprNGfwEbeRWgbNEkqO", result); + } + + @Test + void testFormatClientFirst_usernameEscaping() { + // "=" → "=3D", "," → "=2C" + String result = ScramMessageFormatter.formatClientFirstMessage("us=er,name", "nonce123"); + assertEquals("n,,n=us=3Der=2Cname,r=nonce123", result); + } + + @Test + void testClientFirstMessageBare() { + String result = ScramMessageFormatter.clientFirstMessageBare("user", "rOprNGfwEbeRWgbNEkqO"); + assertEquals("n=user,r=rOprNGfwEbeRWgbNEkqO", result); + } + + @Test + void testGetClientFirstMessage_includesGs2Header() { + String full = ScramMessageFormatter.formatClientFirstMessage("user", "nonce"); + assertTrue(full.startsWith("n,,"), "Full message must start with gs2-header 'n,,'"); + String bare = ScramMessageFormatter.clientFirstMessageBare("user", "nonce"); + assertEquals("n,," + bare, full); + } + + @Test + void testFormatClientFinal() { + byte[] proof = {0x01, 0x02, 0x03, 0x04}; + String result = ScramMessageFormatter.formatClientFinalMessage("fullNonce123", proof); + String expectedProof = Base64.getEncoder().encodeToString(proof); + assertEquals("c=biws,r=fullNonce123,p=" + expectedProof, result); + } + + @Test + void testClientFinalMessageWithoutProof() { + String result = ScramMessageFormatter.clientFinalMessageWithoutProof("fullNonce123"); + assertEquals("c=biws,r=fullNonce123", result); + } + + @Test + void testFormatAuthorizationHeader_initial() { + // Erratum 6558: data attribute MUST be quoted + String result = ScramMessageFormatter.formatAuthorizationHeader( + "SCRAM-SHA-256", "testrealm@example.com", null, "biwsbj11c2VyLHI9bm9uY2U="); + assertEquals("SCRAM-SHA-256 realm=\"testrealm@example.com\", data=\"biwsbj11c2VyLHI9bm9uY2U=\"", result); + // Verify data is quoted + assertTrue(result.contains("data=\"")); + } + + @Test + void testFormatAuthorizationHeader_final() { + String result = ScramMessageFormatter.formatAuthorizationHeader( + "SCRAM-SHA-256", null, "AAAABBBB", "Yz1iaXdzLHI9bm9uY2U="); + assertEquals("SCRAM-SHA-256 sid=AAAABBBB, data=\"Yz1iaXdzLHI9bm9uY2U=\"", result); + } + + @Test + void testFormatAuthorizationHeader_reauth() { + String result = ScramMessageFormatter.formatAuthorizationHeader( + "SCRAM-SHA-256", "myrealm", null, "base64data"); + assertEquals("SCRAM-SHA-256 realm=\"myrealm\", data=\"base64data\"", result); + } + + @Test + void testEscapeUsername() { + assertEquals("user", ScramMessageFormatter.escapeUsername("user")); + assertEquals("us=3Der", ScramMessageFormatter.escapeUsername("us=er")); + assertEquals("us=2Cer", ScramMessageFormatter.escapeUsername("us,er")); + assertEquals("=3D=2C", ScramMessageFormatter.escapeUsername("=,")); + } + + @Test + void testBase64_standardAlphabet() { + // Verify base64 uses standard alphabet (+/) not URL-safe (-_) + byte[] data = {(byte) 0xFB, (byte) 0xEF, (byte) 0xBE}; + String encoded = Base64.getEncoder().encodeToString(data); + assertTrue(encoded.contains("+") || encoded.contains("/"), + "Standard base64 should use +/ characters, not URL-safe -_"); + assertFalse(encoded.contains("-"), "Must not use URL-safe base64"); + assertFalse(encoded.contains("_"), "Must not use URL-safe base64"); + } + + @Test + void testChannelBindingValue() { + // "c=biws" is base64 of "n,," (the gs2-header for no channel binding) + String decoded = new String(Base64.getDecoder().decode("biws")); + assertEquals("n,,", decoded); + } +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java new file mode 100644 index 0000000000..65dbe48e3c --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class ScramMessageParserTest { + + @Test + void testParseServerFirst_valid() { + String message = "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"; + ScramMessageParser.ServerFirstMessage result = ScramMessageParser.parseServerFirst(message); + assertEquals("rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF", result.fullNonce); + assertArrayEquals(Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="), result.salt); + assertEquals(4096, result.iterationCount); + } + + @Test + void testParseServerFirst_missingNonce() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")); + } + + @Test + void testParseServerFirst_missingSalt() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=rOprNGfwEbeRWgbNEkqOsrvr,i=4096")); + } + + @Test + void testParseServerFirst_missingIteration() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=rOprNGfwEbeRWgbNEkqOsrvr,s=W22ZaJ0SNY7soEsUEjb6gQ==")); + } + + @Test + void testParseServerFirst_withExtensions() { + // Extensions after i= should be tolerated + String message = "r=nonce123srvr,s=c2FsdA==,i=4096,ext=value"; + ScramMessageParser.ServerFirstMessage result = ScramMessageParser.parseServerFirst(message); + assertEquals("nonce123srvr", result.fullNonce); + assertEquals(4096, result.iterationCount); + } + + @Test + void testParseServerFirst_invalidIterationCount() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=nonce,s=c2FsdA==,i=abc")); + } + + @Test + void testParseServerFirst_zeroIterationCount() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=nonce,s=c2FsdA==,i=0")); + } + + @Test + void testParseServerFirst_invalidBase64Salt() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=nonce,s=!!!invalid!!!,i=4096")); + } + + @Test + void testParseServerFinal_verifier() { + String message = "v=rmF9pqV8S7suAoZWja4dJRkFsKQ="; + ScramMessageParser.ServerFinalMessage result = ScramMessageParser.parseServerFinal(message); + assertEquals("rmF9pqV8S7suAoZWja4dJRkFsKQ=", result.verifier); + assertNull(result.error); + } + + @Test + void testParseServerFinal_error() { + String message = "e=invalid-proof"; + ScramMessageParser.ServerFinalMessage result = ScramMessageParser.parseServerFinal(message); + assertNull(result.verifier); + assertEquals("invalid-proof", result.error); + } + + @Test + void testParseServerFinal_invalid() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFinal("x=unknown")); + } + + @Test + void testParseWwwAuthenticate_realmAndData() { + String header = "SCRAM-SHA-256 realm=\"testrealm@example.com\", data=\"biwsbj11c2VyLHI9ck9wck5HZndFYmVSV2diTkVrcU8=\""; + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertEquals("testrealm@example.com", params.realm); + assertEquals("biwsbj11c2VyLHI9ck9wck5HZndFYmVSV2diTkVrcU8=", params.data); + assertNull(params.sid); + assertFalse(params.stale); + } + + @Test + void testParseWwwAuthenticate_sidAndData() { + String header = "SCRAM-SHA-256 sid=AAAABBBB, data=\"cj1yT3ByTkdmd0ViZQ==\""; + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertEquals("AAAABBBB", params.sid); + assertEquals("cj1yT3ByTkdmd0ViZQ==", params.data); + } + + @Test + void testParseWwwAuthenticate_srAndTtl() { + String header = "SCRAM-SHA-256 realm=\"test\", sr=serverNonce123, ttl=300"; + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertEquals("test", params.realm); + assertEquals("serverNonce123", params.sr); + assertEquals(300, params.ttl); + } + + @Test + void testParseWwwAuthenticate_staleFlag() { + String header = "SCRAM-SHA-256 realm=\"test\", sr=nonce, stale=true"; + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertTrue(params.stale); + + header = "SCRAM-SHA-256 realm=\"test\", sr=nonce, stale=false"; + params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertFalse(params.stale); + } + + @Test + void testParseWwwAuthenticate_noTtl() { + String header = "SCRAM-SHA-256 realm=\"test\""; + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header); + assertEquals(-1, params.ttl); + } + + @Test + void testValidateGs2Header_valid() { + assertDoesNotThrow(() -> ScramMessageParser.validateGs2Header("n,,n=user,r=nonce")); + } + + @Test + void testValidateGs2Header_invalid_y() { + assertThrows(ScramException.class, () -> ScramMessageParser.validateGs2Header("y,,n=user,r=nonce")); + } + + @Test + void testValidateGs2Header_invalid_p() { + assertThrows(ScramException.class, () -> ScramMessageParser.validateGs2Header("p=tls-unique,,n=user,r=nonce")); + } + + @Test + void testValidateNoncePrefix_valid() { + assertDoesNotThrow(() -> ScramMessageParser.validateNoncePrefix("clientNonce", "clientNonceServerPart")); + } + + @Test + void testValidateNoncePrefix_invalid() { + assertThrows(ScramException.class, () -> ScramMessageParser.validateNoncePrefix("clientNonce", "differentNonce")); + } + + @Test + void testValidateNoncePrefix_empty_server_part() { + assertThrows(ScramException.class, () -> ScramMessageParser.validateNoncePrefix("clientNonce", "clientNonce")); + } +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java new file mode 100644 index 0000000000..d8d1012e5d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Golden tests verifying SCRAM-SHA-256 against independently computed + * values using RFC 7804 §5 and RFC 5802 test vector parameters. + * + * CRITICAL: Values are independently computed from input parameters, NOT copied from + * RFC examples (which may contain trailing newlines per Erratum 6633). + */ +class ScramRfc7804GoldenTest { + + // RFC 7804 / RFC 5802 / RFC 7677 shared parameters + private static final String USERNAME = "user"; + private static final String PASSWORD = "pencil"; + private static final String CLIENT_NONCE = "rOprNGfwEbeRWgbNEkqO"; + private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="); + private static final int ITERATIONS = 4096; + + @Test + void testClientFirstMessage_format() { + // No trailing newline (Erratum 6633) + String clientFirst = ScramMessageFormatter.formatClientFirstMessage(USERNAME, CLIENT_NONCE); + assertEquals("n,,n=user,r=rOprNGfwEbeRWgbNEkqO", clientFirst); + assertFalse(clientFirst.endsWith("\n"), "Must NOT have trailing newline (Erratum 6633)"); + } + + @Test + void testClientFirstMessageBare_format() { + String bare = ScramMessageFormatter.clientFirstMessageBare(USERNAME, CLIENT_NONCE); + assertEquals("n=user,r=rOprNGfwEbeRWgbNEkqO", bare); + } + + @Test + void testClientFirstMessage_base64() { + String clientFirst = ScramMessageFormatter.formatClientFirstMessage(USERNAME, CLIENT_NONCE); + String base64 = Base64.getEncoder().encodeToString(clientFirst.getBytes(StandardCharsets.UTF_8)); + // Verify round-trip + String decoded = new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8); + assertEquals(clientFirst, decoded); + // Standard base64 alphabet (not URL-safe) + assertFalse(base64.contains("-"), "Must use standard base64, not URL-safe"); + assertFalse(base64.contains("_"), "Must use standard base64, not URL-safe"); + } + + @Test + void testSHA256_fullKeyDerivation() { + // Independently compute all intermediate values + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + assertEquals(32, saltedPassword.length); + + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + assertEquals(32, clientKey.length); + + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + assertEquals(32, storedKey.length); + + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + assertEquals(32, serverKey.length); + + // Verify StoredKey = H(ClientKey) + byte[] recomputedStoredKey = ScramEngine.hash(clientKey); + assertArrayEquals(storedKey, recomputedStoredKey); + + ScramEngine.zeroBytes(saltedPassword); + } + + @Test + void testSHA256_clientProofAndServerSignature() { + // Use RFC 7804 nonce suffix + String serverNonceSuffix = "%hvYDpWUa2RaTCAfuxFIlj)hNlF"; + String fullNonce = CLIENT_NONCE + serverNonceSuffix; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + + String clientFirstBare = "n=user,r=" + CLIENT_NONCE; + String serverFirst = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + String clientFinalWithoutProof = "c=biws,r=" + fullNonce; + String authMessage = clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof; + + // Compute all keys + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + + // Compute ClientProof + byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage); + byte[] clientProof = ScramEngine.computeClientProof(clientKey, clientSignature); + assertNotNull(clientProof); + assertEquals(32, clientProof.length); + + // Compute ServerSignature + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + assertNotNull(serverSignature); + assertEquals(32, serverSignature.length); + + // Verify: ClientKey XOR ClientSignature = ClientProof + byte[] recomputedClientKey = ScramEngine.xor(clientProof.clone(), clientSignature); + assertArrayEquals(clientKey, recomputedClientKey); + + ScramEngine.zeroBytes(saltedPassword); + } + + @Test + void testSHA256_fullExchangeConsistency() { + // Verify that ScramContext produces same values as manual computation + String serverNonceSuffix = "ServerNonce123"; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + + // Manual computation + byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS); + byte[] manualClientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] manualStoredKey = ScramEngine.computeStoredKey(manualClientKey); + byte[] manualServerKey = ScramEngine.computeServerKey(saltedPassword); + + // ScramContext computation (uses same algorithm internally) + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM_NAME, "SCRAM-SHA-256"); + String clientNonce = ctx.getClientNonce(); + String fullNonce = clientNonce + serverNonceSuffix; + String serverFirst = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + ctx.processServerFirst(serverFirst, 16_384); + + // Keys should match + assertArrayEquals(manualClientKey, ctx.getClientKey()); + assertArrayEquals(manualStoredKey, ctx.getStoredKey()); + assertArrayEquals(manualServerKey, ctx.getServerKey()); + + // Compute client-final and verify + String clientFinal = ctx.computeClientFinal(); + assertTrue(clientFinal.startsWith("c=biws,r=" + fullNonce + ",p=")); + + // Build and verify server-final + String authMessage = ctx.getClientFirstMessageBare() + "," + serverFirst + "," + + ctx.getClientFinalMessageWithoutProof(); + byte[] serverSignature = ScramEngine.computeServerSignature(manualServerKey, authMessage); + String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature); + assertTrue(ctx.verifyServerFinal(serverFinal)); + + ScramEngine.zeroBytes(saltedPassword); + } + + @Test + void testUsernameEscaping() { + assertEquals("user", ScramMessageFormatter.escapeUsername("user")); + assertEquals("us=3Der", ScramMessageFormatter.escapeUsername("us=er")); + assertEquals("us=2Cer", ScramMessageFormatter.escapeUsername("us,er")); + } + + @Test + void testChannelBindingField() { + // c=biws is base64("n,,") — the gs2-header for no channel binding + String decoded = new String(Base64.getDecoder().decode("biws"), StandardCharsets.UTF_8); + assertEquals("n,,", decoded); + } + + @Test + void testBase64_standardAlphabet() { + // Verify standard (not URL-safe) base64 is used throughout + String encoded = Base64.getEncoder().encodeToString(SALT); + // Standard base64 uses + and / (not - and _) + for (char c : encoded.toCharArray()) { + assertTrue( + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '+' || c == '/' || c == '=', + "Character '" + c + "' is not in standard base64 alphabet" + ); + } + } + + @Test + void testBase64_noTrailingNewlines() { + // Erratum 6633: no trailing newlines in base64 + String clientFirst = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO"; + String encoded = Base64.getEncoder().encodeToString( + clientFirst.getBytes(StandardCharsets.UTF_8)); + assertFalse(encoded.contains("\n"), "Base64 must not contain newlines"); + assertFalse(encoded.contains("\r"), "Base64 must not contain carriage returns"); + } + + @Test + void testDataAttributeQuoting() { + // Erratum 6558: data attribute MUST be quoted + String header = ScramMessageFormatter.formatAuthorizationHeader( + "SCRAM-SHA-256", "test", null, "dGVzdA=="); + assertTrue(header.contains("data=\"dGVzdA==\""), "data attribute must be quoted"); + } + + private static final String REALM_NAME = "testrealm@example.com"; +} diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java new file mode 100644 index 0000000000..40f36f2ca1 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.scram; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ScramSessionCacheTest { + + private ScramSessionCache.Entry createEntry(String sr, int ttl, int initialNonceCount) { + return new ScramSessionCache.Entry( + "testrealm", + new byte[]{1, 2, 3}, 4096, + new byte[]{4, 5, 6}, new byte[]{7, 8, 9}, new byte[]{10, 11, 12}, + sr, ttl, System.nanoTime(), + initialNonceCount, + "r=nonce,s=c2FsdA==,i=4096" + ); + } + + @Test + void testPutAndGet() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096); + + cache.put(key, entry); + assertSame(entry, cache.get(key)); + } + + @Test + void testGet_missing() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + assertNull(cache.get(key)); + } + + @Test + void testIsSrFresh_withinTtl() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096); + assertTrue(cache.isSrFresh(entry)); + } + + @Test + void testIsSrFresh_expired() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.Entry entry = new ScramSessionCache.Entry( + "realm", + new byte[]{1}, 4096, + new byte[]{2}, new byte[]{3}, new byte[]{4}, + "sr123", 0, // 0 second TTL = immediately expired + System.nanoTime() - 1_000_000_000L, // 1 second ago + 4096, + "r=nonce,s=c2FsdA==,i=4096" + ); + assertFalse(cache.isSrFresh(entry)); + } + + @Test + void testIsSrFresh_noTtl() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.Entry entry = createEntry("sr123", -1, 4096); + assertTrue(cache.isSrFresh(entry)); // ttl=-1 → always fresh + } + + @Test + void testIsSrFresh_nullSr() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.Entry entry = createEntry(null, -1, 4096); + assertFalse(cache.isSrFresh(entry)); + } + + @Test + void testNonceCountReserveConfirm() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096); + cache.put(key, entry); + + // Reserve: atomically returns current value (4096) and increments + assertEquals(4096, cache.reserveNonceCount(key)); + + // Reserve again: returns 4097 (already incremented) + assertEquals(4097, cache.reserveNonceCount(key)); + + // Confirm is a no-op since reserve already incremented + cache.confirmNonceCount(key); + assertEquals(4098, cache.reserveNonceCount(key)); + } + + @Test + void testNonceCountRollback() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096); + cache.put(key, entry); + + // Reserve: atomically returns 4096 and increments to 4097 + assertEquals(4096, cache.reserveNonceCount(key)); + + // Rollback: decrements back to 4096 + cache.rollbackNonceCount(key); + + // Should be back to 4096 + assertEquals(4096, cache.reserveNonceCount(key)); + } + + @Test + void testMaxEntries_eviction() { + ScramSessionCache cache = new ScramSessionCache(2); + + ScramSessionCache.CacheKey key1 = new ScramSessionCache.CacheKey("host1", 80, "realm"); + ScramSessionCache.CacheKey key2 = new ScramSessionCache.CacheKey("host2", 80, "realm"); + ScramSessionCache.CacheKey key3 = new ScramSessionCache.CacheKey("host3", 80, "realm"); + + cache.put(key1, createEntry("sr1", 300, 4096)); + cache.put(key2, createEntry("sr2", 300, 4096)); + assertEquals(2, cache.size()); + + // Adding third should evict oldest entry, keeping 2 entries + cache.put(key3, createEntry("sr3", 300, 4096)); + assertEquals(2, cache.size()); + assertNotNull(cache.get(key3)); + } + + @Test + void testUpdateSr() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.Entry entry = createEntry("oldSr", 300, 4096); + cache.put(key, entry); + + cache.updateSr(key, "newSr", 600); + + ScramSessionCache.Entry updated = cache.get(key); + assertNotNull(updated); + assertEquals("newSr", updated.sr); + assertEquals(600, updated.ttl); + } + + @Test + void testClear() { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + cache.put(key, createEntry("sr", 300, 4096)); + + cache.clear(); + assertEquals(0, cache.size()); + assertNull(cache.get(key)); + } + + @Test + void testNoSaltedPasswordStored() { + // Verify that Entry has no SaltedPassword field — only derived keys + ScramSessionCache.Entry entry = createEntry("sr", 300, 4096); + assertNotNull(entry.clientKey); + assertNotNull(entry.storedKey); + assertNotNull(entry.serverKey); + // No saltedPassword field exists — this is verified by compilation + } + + @Test + void testCacheKey_equality() { + ScramSessionCache.CacheKey key1 = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.CacheKey key2 = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.CacheKey key3 = new ScramSessionCache.CacheKey("host", 443, "realm"); + + assertEquals(key1, key2); + assertEquals(key1.hashCode(), key2.hashCode()); + assertNotEquals(key1, key3); + } +} From 2cf6d7591a5463aa434e508c1b227e38909ae840 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 21 Mar 2026 05:35:35 +0000 Subject: [PATCH 2/4] Fix Javadoc errors causing CI build failure --- .../java/org/asynchttpclient/handler/TransferListener.java | 2 +- .../java/org/asynchttpclient/scram/ScramMessageParser.java | 4 ++-- .../java/org/asynchttpclient/scram/ScramSessionCache.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/handler/TransferListener.java b/client/src/main/java/org/asynchttpclient/handler/TransferListener.java index fd50dcf7e6..b6b8198339 100644 --- a/client/src/main/java/org/asynchttpclient/handler/TransferListener.java +++ b/client/src/main/java/org/asynchttpclient/handler/TransferListener.java @@ -36,7 +36,7 @@ public interface TransferListener { /** * Invoked every time response's chunk are received. * - * @param bytes a {@link byte} array + * @param bytes a byte array */ void onBytesReceived(byte[] bytes); diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java index 8cb0a2fa9a..db2d5c560b 100644 --- a/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java +++ b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java @@ -85,7 +85,7 @@ public ScramChallengeParams(@Nullable String realm, @Nullable String data, } /** - * Parse a server-first-message (RFC 5802): r=,s=,i=[,extensions] + * Parse a server-first-message (RFC 5802): {@code r=,s=,i=[,extensions]} */ public static ServerFirstMessage parseServerFirst(String message) { String fullNonce = null; @@ -133,7 +133,7 @@ public static ServerFirstMessage parseServerFirst(String message) { } /** - * Parse a server-final-message (RFC 5802): v= OR e= + * Parse a server-final-message (RFC 5802): {@code v=} OR {@code e=} */ public static ServerFinalMessage parseServerFinal(String message) { if (message.startsWith("v=")) { diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java index aa5a02f341..ce93ca11ae 100644 --- a/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java +++ b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java @@ -139,7 +139,7 @@ public void updateSr(CacheKey key, String newSr, int newTtl) { /** * Check if the sr value is still fresh. * If ttl == -1: always fresh (no expiration). - * If ttl >= 0: check elapsed time since sr was received. + * If ttl >= 0: check elapsed time since sr was received. */ public boolean isSrFresh(Entry entry) { if (entry.sr == null) { From b9852aa80131885cff2b3f3a094c3c479f98f240 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 21 Mar 2026 07:33:48 +0000 Subject: [PATCH 3/4] Add tests --- .../netty/handler/intercept/Interceptors.java | 13 +- .../ProxyUnauthorized407Interceptor.java | 2 +- .../intercept/Unauthorized401Interceptor.java | 2 +- .../asynchttpclient/scram/ScramContext.java | 16 +- .../org/asynchttpclient/ScramAuthTest.java | 257 ++++++++++++++++++ .../scram/ScramContextTest.java | 16 ++ .../scram/ScramMessageParserTest.java | 6 + .../scram/ScramSessionCacheTest.java | 48 ++++ 8 files changed, 350 insertions(+), 10 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java index 69f58b3737..f974f88b0e 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java @@ -202,12 +202,19 @@ private void processScramAuthenticationInfo(NettyResponseFuture future, HttpH return; } - String serverFinalMsg = new String(Base64.getDecoder().decode(data), StandardCharsets.UTF_8); + String serverFinalMsg; + try { + serverFinalMsg = new String(Base64.getDecoder().decode(data), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + LOGGER.warn("SCRAM: invalid base64 in {} data attribute: {}", headerName, e.getMessage()); + ctx.setState(ScramState.FAILED); + return; + } + + // verifyServerFinal sets state to AUTHENTICATED or FAILED internally if (ctx.verifyServerFinal(serverFinalMsg)) { - ctx.setState(ScramState.AUTHENTICATED); LOGGER.debug("SCRAM ServerSignature verified successfully"); } else { - ctx.setState(ScramState.FAILED); LOGGER.warn("SCRAM ServerSignature verification failed — authentication unsuccessful " + "(RFC 7804 §5: MUST consider unsuccessful)"); } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java index 62ea640274..74f8c728ef 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java @@ -274,7 +274,7 @@ public boolean exitAfterHandling407(Channel channel, NettyResponseFuture futu LOGGER.warn("SCRAM proxy authentication failed: unexpected 407 in state {}", ctx.getState()); return false; } - } catch (ScramException e) { + } catch (ScramException | IllegalArgumentException e) { LOGGER.warn("SCRAM proxy authentication failed: {}", e.getMessage()); return false; } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java index 0b6f45b2c0..237755687f 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java @@ -268,7 +268,7 @@ public boolean exitAfterHandling401(Channel channel, NettyResponseFuture futu LOGGER.warn("SCRAM authentication failed: unexpected 401 in state {}", ctx.getState()); return false; } - } catch (ScramException e) { + } catch (ScramException | IllegalArgumentException e) { LOGGER.warn("SCRAM authentication failed: {}", e.getMessage()); return false; } diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramContext.java b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java index 2b85b6516c..68ba8d06ab 100644 --- a/client/src/main/java/org/asynchttpclient/scram/ScramContext.java +++ b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java @@ -155,7 +155,13 @@ public boolean verifyServerFinal(String serverFinalMsg) { String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalNoProof; byte[] expectedServerSignature = ScramEngine.computeServerSignature(currentServerKey, authMessage); - byte[] receivedSignature = Base64.getDecoder().decode(parsed.verifier); + byte[] receivedSignature; + try { + receivedSignature = Base64.getDecoder().decode(parsed.verifier); + } catch (IllegalArgumentException e) { + this.state = ScramState.FAILED; + return false; + } // Constant-time comparison to prevent timing side-channel attacks if (MessageDigest.isEqual(expectedServerSignature, receivedSignature)) { @@ -243,15 +249,15 @@ public String getClientFirstMessageBare() { } public @Nullable byte[] getClientKey() { - return clientKey; + return clientKey != null ? clientKey.clone() : null; } public @Nullable byte[] getStoredKey() { - return storedKey; + return storedKey != null ? storedKey.clone() : null; } public @Nullable byte[] getServerKey() { - return serverKey; + return serverKey != null ? serverKey.clone() : null; } public @Nullable ScramMessageParser.ScramChallengeParams getInitialChallengeParams() { @@ -267,7 +273,7 @@ public int getIterationCount() { } public @Nullable byte[] getSalt() { - return salt; + return salt != null ? salt.clone() : null; } public @Nullable String getClientFinalMessageWithoutProof() { diff --git a/client/src/test/java/org/asynchttpclient/ScramAuthTest.java b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java index 275c7fb7a0..65810161f5 100644 --- a/client/src/test/java/org/asynchttpclient/ScramAuthTest.java +++ b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java @@ -36,6 +36,7 @@ import java.util.concurrent.TimeUnit; import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.proxyServer; import static org.asynchttpclient.Dsl.scramSha256AuthRealm; import static org.asynchttpclient.test.TestUtils.ADMIN; import static org.asynchttpclient.test.TestUtils.USER; @@ -124,6 +125,26 @@ public void testScramSha256_missingAuthInfo() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_malformedBase64InData() throws Exception { + server.stop(); + server = new Server(); + ServerConnector connector = addHttpConnector(server); + server.setHandler(new MalformedBase64ScramHandler()); + server.start(); + port1 = connector.getLocalPort(); + + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build()) + .execute(); + Response resp = f.get(20, TimeUnit.SECONDS); + assertNotNull(resp); + // Client should handle malformed base64 gracefully — no crash, returns 401 + assertEquals(401, resp.getStatusCode()); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void testScramSha256_quotedDataAttribute() throws Exception { try (AsyncHttpClient client = asyncHttpClient()) { @@ -138,6 +159,29 @@ public void testScramSha256_quotedDataAttribute() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void testScramSha256_proxyFullExchange() throws Exception { + server.stop(); + server = new Server(); + ServerConnector connector = addHttpConnector(server); + server.setHandler(new ScramProxyHandler()); + server.start(); + int proxyPort = connector.getLocalPort(); + + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost/") + .setProxyServer(proxyServer("localhost", proxyPort) + .setRealm(scramSha256AuthRealm(USER, ADMIN) + .setRealmName("ScramRealm") + .build()) + .build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + } + } + /** * Server-side SCRAM-SHA-256 authenticator for testing. * Implements the full 3-step SCRAM handshake: @@ -334,4 +378,217 @@ public void handle(String s, Request r, HttpServletRequest request, HttpServletR } } } + + /** + * Proxy-side SCRAM-SHA-256 authenticator for testing the 407 proxy authentication flow. + * Uses Proxy-Authorization/Proxy-Authenticate headers and 407 status codes. + */ + private static class ScramProxyHandler extends AbstractHandler { + + private static final String REALM = "ScramRealm"; + private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="); + private static final int ITERATIONS = 4096; + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException { + String authz = request.getHeader("Proxy-Authorization"); + + if (authz == null) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setHeader("Proxy-Authenticate", "SCRAM-SHA-256 realm=\"" + REALM + "\""); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + if (!authz.startsWith("SCRAM-SHA-256")) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz); + + if (params.sid == null && params.data != null) { + String clientFirstFull = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + + if (!clientFirstFull.startsWith("n,,")) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + String clientFirstBare = clientFirstFull.substring(3); + String clientNonce = null; + String clientUsername = null; + for (String part : clientFirstBare.split(",")) { + if (part.startsWith("r=")) { + clientNonce = part.substring(2); + } else if (part.startsWith("n=")) { + clientUsername = part.substring(2); + } + } + + if (clientNonce == null || clientUsername == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + if (!USER.equals(clientUsername.replace("=3D", "=").replace("=2C", ","))) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + String serverNoncePart = ScramEngine.generateNonce(18); + String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + + sessions.put(sid, new ScramAuthHandler.ServerSession(clientNonce, serverNoncePart, clientFirstBare)); + + String fullNonce = clientNonce + serverNoncePart; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + String base64ServerFirst = Base64.getEncoder().encodeToString(serverFirstMsg.getBytes(StandardCharsets.UTF_8)); + + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setHeader("Proxy-Authenticate", "SCRAM-SHA-256 sid=" + sid + ", data=\"" + base64ServerFirst + "\""); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + + } else if (params.sid != null && params.data != null) { + ScramAuthHandler.ServerSession session = sessions.remove(params.sid); + if (session == null) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + String clientFinalMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8); + + String clientFinalNonce = null; + String clientProofBase64 = null; + for (String part : clientFinalMsg.split(",")) { + if (part.startsWith("r=")) { + clientFinalNonce = part.substring(2); + } else if (part.startsWith("p=")) { + clientProofBase64 = part.substring(2); + } + } + + if (clientFinalNonce == null || clientProofBase64 == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + if (!clientFinalNonce.equals(session.fullNonce)) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + byte[] saltedPassword = ScramEngine.computeSaltedPassword(ADMIN, SALT, ITERATIONS); + byte[] clientKey = ScramEngine.computeClientKey(saltedPassword); + byte[] storedKey = ScramEngine.computeStoredKey(clientKey); + byte[] serverKey = ScramEngine.computeServerKey(saltedPassword); + + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + session.fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS; + String clientFinalWithoutProof = "c=biws,r=" + session.fullNonce; + String authMessage = session.clientFirstBare + "," + serverFirstMsg + "," + clientFinalWithoutProof; + + byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage); + byte[] receivedProof = Base64.getDecoder().decode(clientProofBase64); + byte[] recoveredClientKey = ScramEngine.xor(receivedProof.clone(), clientSignature); + + byte[] recoveredStoredKey = ScramEngine.hash(recoveredClientKey); + boolean proofValid = Arrays.equals(storedKey, recoveredStoredKey); + + ScramEngine.zeroBytes(saltedPassword); + + if (!proofValid) { + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + return; + } + + byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage); + String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature); + String base64ServerFinal = Base64.getEncoder().encodeToString(serverFinal.getBytes(StandardCharsets.UTF_8)); + + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Proxy-Authentication-Info", "sid=" + params.sid + ", data=\"" + base64ServerFinal + "\""); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + + } else { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentLength(0); + response.getOutputStream().flush(); + response.getOutputStream().close(); + } + } + } + + /** + * Handler that sends malformed base64 in the SCRAM data attribute (step 2). + * Used to test that the client handles IllegalArgumentException from Base64 gracefully. + */ + private static class MalformedBase64ScramHandler extends AbstractHandler { + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException { + String authz = request.getHeader("Authorization"); + + if (authz == null) { + // Step 1: Send initial challenge + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 realm=\"ScramRealm\""); + response.getOutputStream().close(); + return; + } + + if (!authz.startsWith("SCRAM-SHA-256")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + return; + } + + ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz); + + if (params.sid == null && params.data != null) { + // Step 2: Send malformed base64 in data attribute + String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", + "SCRAM-SHA-256 sid=" + sid + ", data=\"!!!invalid-base64!!!\""); + response.getOutputStream().close(); + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getOutputStream().close(); + } + } + } } diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java index feacd19cca..b73eb4ef55 100644 --- a/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java +++ b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java @@ -123,6 +123,22 @@ void testVerifyServerFinal_serverError() { assertEquals(ScramState.FAILED, ctx.getState()); } + @Test + void testProcessServerFirst_minimumIterationCount() { + // RFC 5802 doesn't specify a minimum iteration count. + // Server sends i=1 — should be accepted (documents that no minimum is enforced). + ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); + String clientNonce = ctx.getClientNonce(); + String fullNonce = clientNonce + "serverpart"; + String saltBase64 = Base64.getEncoder().encodeToString(SALT); + String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=1"; + + // Should not throw — i=1 is accepted + assertDoesNotThrow(() -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS)); + assertEquals(ScramState.SERVER_FIRST_RECEIVED, ctx.getState()); + assertEquals(1, ctx.getIterationCount()); + } + @Test void testGetClientFirstMessage_includesGs2Header() { ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256"); diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java index 65dbe48e3c..6f0b1e414a 100644 --- a/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java +++ b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java @@ -144,6 +144,12 @@ void testParseWwwAuthenticate_noTtl() { assertEquals(-1, params.ttl); } + @Test + void testParseServerFirst_emptySalt() { + assertThrows(ScramException.class, () -> + ScramMessageParser.parseServerFirst("r=nonce,s=,i=4096")); + } + @Test void testValidateGs2Header_valid() { assertDoesNotThrow(() -> ScramMessageParser.validateGs2Header("n,,n=user,r=nonce")); diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java index 40f36f2ca1..e376de463c 100644 --- a/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java +++ b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java @@ -17,6 +17,14 @@ import org.junit.jupiter.api.Test; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + import static org.junit.jupiter.api.Assertions.*; class ScramSessionCacheTest { @@ -174,6 +182,46 @@ void testNoSaltedPasswordStored() { // No saltedPassword field exists — this is verified by compilation } + @Test + void testConcurrentNonceCountReservation() throws Exception { + ScramSessionCache cache = new ScramSessionCache(); + ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm"); + ScramSessionCache.Entry entry = createEntry("sr123", 300, 0); + cache.put(key, entry); + + int threadCount = 16; + int reservationsPerThread = 1000; + int totalReservations = threadCount * reservationsPerThread; + Set reservedValues = Collections.newSetFromMap(new ConcurrentHashMap<>()); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + for (int t = 0; t < threadCount; t++) { + executor.submit(() -> { + try { + startLatch.await(); + for (int i = 0; i < reservationsPerThread; i++) { + int value = cache.reserveNonceCount(key); + assertTrue(reservedValues.add(value), + "Duplicate nonce count reserved: " + value); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not complete in time"); + executor.shutdown(); + + assertEquals(totalReservations, reservedValues.size(), + "All reserved nonce counts must be unique"); + } + @Test void testCacheKey_equality() { ScramSessionCache.CacheKey key1 = new ScramSessionCache.CacheKey("host", 80, "realm"); From e6ca3d19faa44ea064a6de2d3532c6e0c11e7df5 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 21 Mar 2026 21:59:37 +0000 Subject: [PATCH 4/4] Update README with example --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 303200eb5a..1829a120ea 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It supports HTTP/1.1, HTTP/2, and WebSocket protocols. - **Asynchronous API** — non-blocking I/O with `ListenableFuture` and `CompletableFuture` - **Compression** — automatic gzip, deflate, Brotli, and Zstd decompression -- **Authentication** — Basic, Digest, NTLM, and SPNEGO/Kerberos +- **Authentication** — Basic, Digest, NTLM, SPNEGO/Kerberos, and SCRAM-SHA-256 - **Proxy** — HTTP, SOCKS4, and SOCKS5 with CONNECT tunneling - **Native transports** — optional Epoll, KQueue, and io_uring - **Request/response filters** — intercept and transform at each stage @@ -99,13 +99,13 @@ AsyncHttpClient client = asyncHttpClient(config().setUseNativeTransport(true)); com.aayushatharva.brotli4j brotli4j - 1.18.0 + 1.20.0 com.github.luben zstd-jni - 1.5.7-2 + 1.5.7-7 ``` @@ -385,9 +385,16 @@ Response response = client .setRealm(digestAuthRealm("user", "password").build()) .execute() .get(); + +// SCRAM-SHA-256 (RFC 7804) +Response response = client + .prepareGet("https://api.example.com/protected") + .setRealm(scramSha256AuthRealm("user", "password").build()) + .execute() + .get(); ``` -Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**. +Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**, **SCRAM-SHA-256**. ## Proxy Support