Skip to content

Commit 4014599

Browse files
authored
RFC 7804 - SCRAM Authentication Implementation with SHA-256 (#2154)
Motivation: Add SCRAM-SHA-256 HTTP authentication (RFC 7804) — mutual password-based auth with PBKDF2 key derivation that never transmits the password over the wire. Modification: Add SCRAM_SHA_256 to AuthScheme, ScramEngine for crypto (PBKDF2, HMAC-SHA-256, RFC 7613 password normalization), ScramContext for per-exchange state, ScramSessionCache for reauthentication, and ScramMessageParser/ScramMessageFormatter for RFC 5802 wire format. Result: Added RFC 7804 SCRAM-SHA-256 support
1 parent d08c2ad commit 4014599

25 files changed

+3177
-12
lines changed

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ It supports HTTP/1.1, HTTP/2, and WebSocket protocols.
3333
- **Asynchronous API** — non-blocking I/O with `ListenableFuture`
3434
and `CompletableFuture`
3535
- **Compression** — automatic gzip, deflate, Brotli, and Zstd decompression
36-
- **Authentication** — Basic, Digest, NTLM, and SPNEGO/Kerberos
36+
- **Authentication** — Basic, Digest, NTLM, SPNEGO/Kerberos, and SCRAM-SHA-256
3737
- **Proxy** — HTTP, SOCKS4, and SOCKS5 with CONNECT tunneling
3838
- **Native transports** — optional Epoll, KQueue, and io_uring
3939
- **Request/response filters** — intercept and transform at each stage
@@ -99,13 +99,13 @@ AsyncHttpClient client = asyncHttpClient(config().setUseNativeTransport(true));
9999
<dependency>
100100
<groupId>com.aayushatharva.brotli4j</groupId>
101101
<artifactId>brotli4j</artifactId>
102-
<version>1.18.0</version>
102+
<version>1.20.0</version>
103103
</dependency>
104104

105105
<dependency>
106106
<groupId>com.github.luben</groupId>
107107
<artifactId>zstd-jni</artifactId>
108-
<version>1.5.7-2</version>
108+
<version>1.5.7-7</version>
109109
</dependency>
110110
```
111111

@@ -385,9 +385,16 @@ Response response = client
385385
.setRealm(digestAuthRealm("user", "password").build())
386386
.execute()
387387
.get();
388+
389+
// SCRAM-SHA-256 (RFC 7804)
390+
Response response = client
391+
.prepareGet("https://api.example.com/protected")
392+
.setRealm(scramSha256AuthRealm("user", "password").build())
393+
.execute()
394+
.get();
388395
```
389396

390-
Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**.
397+
Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**, **SCRAM-SHA-256**.
391398

392399
## Proxy Support
393400

client/src/main/java/org/asynchttpclient/Dsl.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ public static Realm.Builder realm(Realm prototype) {
113113
.setUseCanonicalHostname(prototype.isUseCanonicalHostname())
114114
.setCustomLoginConfig(prototype.getCustomLoginConfig())
115115
.setLoginContextName(prototype.getLoginContextName())
116-
.setUserhash(prototype.isUserhash());
116+
.setUserhash(prototype.isUserhash())
117+
.setSid(prototype.getSid())
118+
.setMaxIterationCount(prototype.getMaxIterationCount());
117119
// Note: stale is NOT copied — it's challenge-specific, always starts false
118120
}
119121

@@ -132,4 +134,9 @@ public static Realm.Builder digestAuthRealm(String principal, String password) {
132134
public static Realm.Builder ntlmAuthRealm(String principal, String password) {
133135
return realm(AuthScheme.NTLM, principal, password);
134136
}
137+
138+
public static Realm.Builder scramSha256AuthRealm(String principal, String password) {
139+
return realm(AuthScheme.SCRAM_SHA_256, principal, password);
140+
}
141+
135142
}

client/src/main/java/org/asynchttpclient/Realm.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ public class Realm {
6969
private final @Nullable String loginContextName;
7070
private final boolean stale;
7171
private final boolean userhash;
72+
private final @Nullable String sid;
73+
private final int maxIterationCount;
7274

7375
private Realm(@Nullable AuthScheme scheme,
7476
@Nullable String principal,
@@ -93,7 +95,9 @@ private Realm(@Nullable AuthScheme scheme,
9395
@Nullable Map<String, String> customLoginConfig,
9496
@Nullable String loginContextName,
9597
boolean stale,
96-
boolean userhash) {
98+
boolean userhash,
99+
@Nullable String sid,
100+
int maxIterationCount) {
97101

98102
this.scheme = requireNonNull(scheme, "scheme");
99103
this.principal = principal;
@@ -119,6 +123,8 @@ private Realm(@Nullable AuthScheme scheme,
119123
this.loginContextName = loginContextName;
120124
this.stale = stale;
121125
this.userhash = userhash;
126+
this.sid = sid;
127+
this.maxIterationCount = maxIterationCount;
122128
}
123129

124130
public @Nullable String getPrincipal() {
@@ -232,6 +238,14 @@ public boolean isUserhash() {
232238
return userhash;
233239
}
234240

241+
public @Nullable String getSid() {
242+
return sid;
243+
}
244+
245+
public int getMaxIterationCount() {
246+
return maxIterationCount;
247+
}
248+
235249
@Override
236250
public String toString() {
237251
return "Realm{" +
@@ -261,7 +275,7 @@ public String toString() {
261275
}
262276

263277
public enum AuthScheme {
264-
BASIC, DIGEST, NTLM, SPNEGO, KERBEROS
278+
BASIC, DIGEST, NTLM, SPNEGO, KERBEROS, SCRAM_SHA_256
265279
}
266280

267281
/**
@@ -300,6 +314,8 @@ public static class Builder {
300314
private boolean stale;
301315
private boolean userhash;
302316
private @Nullable String entityBodyHash;
317+
private @Nullable String sid;
318+
private int maxIterationCount = 16_384;
303319

304320
public Builder() {
305321
principal = null;
@@ -432,6 +448,16 @@ public Builder setEntityBodyHash(@Nullable String entityBodyHash) {
432448
return this;
433449
}
434450

451+
public Builder setSid(@Nullable String sid) {
452+
this.sid = sid;
453+
return this;
454+
}
455+
456+
public Builder setMaxIterationCount(int maxIterationCount) {
457+
this.maxIterationCount = maxIterationCount;
458+
return this;
459+
}
460+
435461
public @Nullable String getQopValue() {
436462
return qop;
437463
}
@@ -720,7 +746,9 @@ public Realm build() {
720746
customLoginConfig,
721747
loginContextName,
722748
stale,
723-
userhash);
749+
userhash,
750+
sid,
751+
maxIterationCount);
724752
}
725753
}
726754
}

client/src/main/java/org/asynchttpclient/handler/TransferListener.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public interface TransferListener {
3636
/**
3737
* Invoked every time response's chunk are received.
3838
*
39-
* @param bytes a {@link byte} array
39+
* @param bytes a byte array
4040
*/
4141
void onBytesReceived(byte[] bytes);
4242

client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.asynchttpclient.netty.request.NettyRequest;
2828
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
2929
import org.asynchttpclient.proxy.ProxyServer;
30+
import org.asynchttpclient.scram.ScramContext;
3031
import org.asynchttpclient.uri.Uri;
3132
import org.slf4j.Logger;
3233
import org.slf4j.LoggerFactory;
@@ -126,6 +127,7 @@ public final class NettyResponseFuture<V> implements ListenableFuture<V> {
126127
private boolean allowConnect;
127128
private Realm realm;
128129
private Realm proxyRealm;
130+
private volatile ScramContext scramContext;
129131

130132
public NettyResponseFuture(Request originalRequest,
131133
AsyncHandler<V> asyncHandler,
@@ -540,6 +542,14 @@ public void setProxyRealm(Realm proxyRealm) {
540542
this.proxyRealm = proxyRealm;
541543
}
542544

545+
public ScramContext getScramContext() {
546+
return scramContext;
547+
}
548+
549+
public void setScramContext(ScramContext scramContext) {
550+
this.scramContext = scramContext;
551+
}
552+
543553
@Override
544554
public String toString() {
545555
return "NettyResponseFuture{" + //

client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@
3232
import org.asynchttpclient.netty.channel.ChannelManager;
3333
import org.asynchttpclient.netty.request.NettyRequestSender;
3434
import org.asynchttpclient.proxy.ProxyServer;
35+
import org.asynchttpclient.scram.ScramContext;
36+
import org.asynchttpclient.scram.ScramMessageParser;
37+
import org.asynchttpclient.scram.ScramState;
3538
import org.asynchttpclient.util.AuthenticatorUtils;
3639
import org.asynchttpclient.util.NonceCounter;
3740
import org.slf4j.Logger;
3841
import org.slf4j.LoggerFactory;
3942

43+
import java.nio.charset.StandardCharsets;
44+
import java.util.Base64;
45+
4046
import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
4147
import static org.asynchttpclient.Dsl.realm;
4248
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.CONTINUE_100;
@@ -128,6 +134,14 @@ public boolean exitAfterIntercept(Channel channel, NettyResponseFuture<?> future
128134
processAuthenticationInfo(future, responseHeaders, proxyRealm, true);
129135
}
130136

137+
// Process SCRAM Authentication-Info (RFC 7804 §5)
138+
if (realm != null && realm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) {
139+
processScramAuthenticationInfo(future, responseHeaders, "Authentication-Info");
140+
}
141+
if (proxyRealm != null && proxyRealm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) {
142+
processScramAuthenticationInfo(future, responseHeaders, "Proxy-Authentication-Info");
143+
}
144+
131145
return false;
132146
}
133147

@@ -166,4 +180,43 @@ private void processAuthenticationInfo(NettyResponseFuture<?> future, HttpHeader
166180
}
167181
}
168182
}
183+
184+
private void processScramAuthenticationInfo(NettyResponseFuture<?> future, HttpHeaders responseHeaders,
185+
String headerName) {
186+
ScramContext ctx = future.getScramContext();
187+
if (ctx == null || ctx.getState() != ScramState.CLIENT_FINAL_SENT) {
188+
return;
189+
}
190+
191+
String authInfo = responseHeaders.get(headerName);
192+
if (authInfo == null) {
193+
// RFC 7804 §6: may be in chunked trailers (not supported by AHC)
194+
LOGGER.warn("SCRAM: response without {} header; "
195+
+ "ServerSignature cannot be verified (may be in chunked trailers)", headerName);
196+
return;
197+
}
198+
199+
String data = Realm.Builder.matchParam(authInfo, "data");
200+
if (data == null) {
201+
LOGGER.warn("SCRAM: Authentication-Info header missing data attribute");
202+
return;
203+
}
204+
205+
String serverFinalMsg;
206+
try {
207+
serverFinalMsg = new String(Base64.getDecoder().decode(data), StandardCharsets.UTF_8);
208+
} catch (IllegalArgumentException e) {
209+
LOGGER.warn("SCRAM: invalid base64 in {} data attribute: {}", headerName, e.getMessage());
210+
ctx.setState(ScramState.FAILED);
211+
return;
212+
}
213+
214+
// verifyServerFinal sets state to AUTHENTICATED or FAILED internally
215+
if (ctx.verifyServerFinal(serverFinalMsg)) {
216+
LOGGER.debug("SCRAM ServerSignature verified successfully");
217+
} else {
218+
LOGGER.warn("SCRAM ServerSignature verification failed — authentication unsuccessful "
219+
+ "(RFC 7804 §5: MUST consider unsuccessful)");
220+
}
221+
}
169222
}

client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@
3232
import io.netty.handler.codec.http2.Http2StreamChannel;
3333
import org.asynchttpclient.ntlm.NtlmEngine;
3434
import org.asynchttpclient.proxy.ProxyServer;
35+
import org.asynchttpclient.scram.ScramContext;
36+
import org.asynchttpclient.scram.ScramException;
37+
import org.asynchttpclient.scram.ScramMessageFormatter;
38+
import org.asynchttpclient.scram.ScramMessageParser;
39+
import org.asynchttpclient.scram.ScramState;
3540
import org.asynchttpclient.spnego.SpnegoEngine;
3641
import org.asynchttpclient.spnego.SpnegoEngineException;
3742
import org.asynchttpclient.util.AuthenticatorUtils;
3843
import org.asynchttpclient.util.NonceCounter;
3944
import org.slf4j.Logger;
4045
import org.slf4j.LoggerFactory;
4146

47+
import java.nio.charset.StandardCharsets;
48+
import java.util.Base64;
4249
import java.util.List;
4350

4451
import static io.netty.handler.codec.http.HttpHeaderNames.PROXY_AUTHENTICATE;
@@ -214,6 +221,65 @@ public boolean exitAfterHandling407(Channel channel, NettyResponseFuture<?> futu
214221
}
215222
}
216223
break;
224+
case SCRAM_SHA_256:
225+
String scramPrefix = "SCRAM-SHA-256";
226+
String scramHeader = getHeaderWithPrefix(proxyAuthHeaders, scramPrefix);
227+
if (scramHeader == null) {
228+
LOGGER.info("Can't handle 407 with SCRAM realm as Proxy-Authenticate headers don't match");
229+
return false;
230+
}
231+
232+
try {
233+
ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(scramHeader);
234+
ScramContext ctx = future.getScramContext();
235+
236+
if (ctx == null) {
237+
ctx = new ScramContext(proxyRealm.getPrincipal(), proxyRealm.getPassword(),
238+
params.realm != null ? params.realm : proxyRealm.getRealmName(),
239+
scramPrefix);
240+
ctx.setInitialChallengeParams(params);
241+
242+
String base64Data = Base64.getEncoder().encodeToString(
243+
ctx.getClientFirstMessage().getBytes(StandardCharsets.UTF_8));
244+
String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
245+
ctx.getMechanism(), ctx.getRealmName(), null, base64Data);
246+
247+
requestHeaders.set(PROXY_AUTHORIZATION, authHeader);
248+
future.setScramContext(ctx);
249+
future.setInProxyAuth(false);
250+
251+
} else if (ctx.getState() == ScramState.CLIENT_FIRST_SENT) {
252+
if (params.sid == null) {
253+
LOGGER.warn("SCRAM: missing sid in proxy server-first response");
254+
return false;
255+
}
256+
if (params.data == null) {
257+
LOGGER.warn("SCRAM: missing data in proxy server-first response");
258+
return false;
259+
}
260+
261+
String serverFirstMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
262+
ctx.processServerFirst(serverFirstMsg, proxyRealm.getMaxIterationCount());
263+
ctx.setSid(params.sid);
264+
265+
String clientFinalMsg = ctx.computeClientFinal();
266+
String base64Data = Base64.getEncoder().encodeToString(
267+
clientFinalMsg.getBytes(StandardCharsets.UTF_8));
268+
String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
269+
ctx.getMechanism(), null, params.sid, base64Data);
270+
271+
requestHeaders.set(PROXY_AUTHORIZATION, authHeader);
272+
273+
} else {
274+
LOGGER.warn("SCRAM proxy authentication failed: unexpected 407 in state {}", ctx.getState());
275+
return false;
276+
}
277+
} catch (ScramException | IllegalArgumentException e) {
278+
LOGGER.warn("SCRAM proxy authentication failed: {}", e.getMessage());
279+
return false;
280+
}
281+
break;
282+
217283
default:
218284
throw new IllegalStateException("Invalid Authentication scheme " + proxyRealm.getScheme());
219285
}

client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
9595
// We must allow auth handling again.
9696
future.setInAuth(false);
9797
future.setInProxyAuth(false);
98+
future.setScramContext(null);
9899

99100
String originalMethod = request.getMethod();
100101
boolean switchToGet = !originalMethod.equals(GET) &&
@@ -196,7 +197,8 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole
196197
headers.remove(CONTENT_TYPE);
197198
}
198199

199-
if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) {
200+
if (stripAuthorization || (realm != null && (realm.getScheme() == AuthScheme.NTLM
201+
|| realm.getScheme() == AuthScheme.SCRAM_SHA_256))) {
200202
headers.remove(AUTHORIZATION)
201203
.remove(PROXY_AUTHORIZATION);
202204
}

0 commit comments

Comments
 (0)