diff --git a/client/src/main/java/org/asynchttpclient/Realm.java b/client/src/main/java/org/asynchttpclient/Realm.java index c6b70a7de..257ed3916 100644 --- a/client/src/main/java/org/asynchttpclient/Realm.java +++ b/client/src/main/java/org/asynchttpclient/Realm.java @@ -24,6 +24,7 @@ import java.nio.charset.Charset; import java.security.MessageDigest; +import java.util.Arrays; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; @@ -31,10 +32,10 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Objects.requireNonNull; import static org.asynchttpclient.util.HttpConstants.Methods.GET; -import static org.asynchttpclient.util.MessageDigestUtils.pooledMd5MessageDigest; import static org.asynchttpclient.util.MiscUtils.isNonEmpty; import static org.asynchttpclient.util.StringUtils.appendBase16; import static org.asynchttpclient.util.StringUtils.toHexString; +import org.asynchttpclient.util.MessageDigestUtils; /** * This class is required when authentication is needed. The class support @@ -275,6 +276,7 @@ public static class Builder { private String ntlmHost = "localhost"; private boolean useAbsoluteURI; private boolean omitQuery; + private Charset digestCharset = ISO_8859_1; // RFC default /** * Kerberos/Spnego properties */ @@ -282,6 +284,7 @@ public static class Builder { private @Nullable String servicePrincipalName; private boolean useCanonicalHostname; private @Nullable String loginContextName; + private @Nullable String cs; public Builder() { principal = null; @@ -424,6 +427,10 @@ public Builder parseWWWAuthenticateHeader(String headerLine) { .setOpaque(match(headerLine, "opaque")) .setScheme(isNonEmpty(nonce) ? AuthScheme.DIGEST : AuthScheme.BASIC); String algorithm = match(headerLine, "algorithm"); + String cs = match(headerLine, "charset"); + if ("UTF-8".equalsIgnoreCase(cs)) { + this.digestCharset = UTF_8; + } if (isNonEmpty(algorithm)) { setAlgorithm(algorithm); } @@ -452,42 +459,49 @@ public Builder parseProxyAuthenticateHeader(String headerLine) { return this; } - private void newCnonce(MessageDigest md) { - byte[] b = new byte[8]; - ThreadLocalRandom.current().nextBytes(b); - b = md.digest(b); - cnonce = toHexString(b); - } - /** - * TODO: A Pattern/Matcher may be better. + * Extracts the value of a token from a WWW-Authenticate or Proxy-Authenticate header line. + * Example: match('Digest realm="test", nonce="abc"', "realm") returns "test" */ private static @Nullable String match(String headerLine, String token) { - if (headerLine == null) { - return null; - } - - int match = headerLine.indexOf(token); - if (match <= 0) { - return null; - } + if (headerLine == null || token == null) return null; + String pattern = token + "=\""; + int start = headerLine.indexOf(pattern); + if (start == -1) return null; + start += pattern.length(); + int end = headerLine.indexOf('"', start); + if (end == -1) return null; + return headerLine.substring(start, end); + } - // = to skip - match += token.length() + 1; - int trailingComa = headerLine.indexOf(',', match); - String value = headerLine.substring(match, trailingComa > 0 ? trailingComa : headerLine.length()); - value = value.length() > 0 && value.charAt(value.length() - 1) == '"' - ? value.substring(0, value.length() - 1) - : value; - return value.charAt(0) == '"' ? value.substring(1) : value; + private void newCnonce(MessageDigest md) { + byte[] b = new byte[8]; + ThreadLocalRandom.current().nextBytes(b); + byte[] full = md.digest(b); + // trim to first 8 bytes → 16 hex chars + byte[] small = Arrays.copyOf(full, Math.min(8, full.length)); + cnonce = toHexString(small); } - private static byte[] md5FromRecycledStringBuilder(StringBuilder sb, MessageDigest md) { - md.update(StringUtils.charSequence2ByteBuffer(sb, ISO_8859_1)); + private static byte[] digestFromRecycledStringBuilder(StringBuilder sb, MessageDigest md, Charset enc) { + md.update(StringUtils.charSequence2ByteBuffer(sb, enc)); sb.setLength(0); return md.digest(); } + private static MessageDigest getDigestInstance(String algorithm) { + if ("SHA-512/256".equalsIgnoreCase(algorithm)) algorithm = "SHA-512-256"; + if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "MD5-sess".equalsIgnoreCase(algorithm)) { + return MessageDigestUtils.pooledMd5MessageDigest(); + } else if ("SHA-256".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm)) { + return MessageDigestUtils.pooledSha256MessageDigest(); + } else if ("SHA-512-256".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) { + return MessageDigestUtils.pooledSha512_256MessageDigest(); + } else { + throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm); + } + } + private byte[] ha1(StringBuilder sb, MessageDigest md) { // if algorithm is "MD5" or is unspecified => A1 = username ":" realm-value ":" // passwd @@ -495,19 +509,18 @@ private byte[] ha1(StringBuilder sb, MessageDigest md) { // passwd ) ":" nonce-value ":" cnonce-value sb.append(principal).append(':').append(realmName).append(':').append(password); - byte[] core = md5FromRecycledStringBuilder(sb, md); + byte[] core = digestFromRecycledStringBuilder(sb, md, digestCharset); - if (algorithm == null || "MD5".equals(algorithm)) { + if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "SHA-256".equalsIgnoreCase(algorithm) || "SHA-512-256".equalsIgnoreCase(algorithm)) { // A1 = username ":" realm-value ":" passwd return core; } - if ("MD5-sess".equals(algorithm)) { - // A1 = MD5(username ":" realm-value ":" passwd ) ":" nonce ":" cnonce + if ("MD5-sess".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) { + // A1 = HASH(username ":" realm-value ":" passwd ) ":" nonce ":" cnonce appendBase16(sb, core); sb.append(':').append(nonce).append(':').append(cnonce); - return md5FromRecycledStringBuilder(sb, md); + return digestFromRecycledStringBuilder(sb, md, digestCharset); } - throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm); } @@ -526,7 +539,7 @@ private byte[] ha2(StringBuilder sb, String digestUri, MessageDigest md) { throw new UnsupportedOperationException("Digest qop not supported: " + qop); } - return md5FromRecycledStringBuilder(sb, md); + return digestFromRecycledStringBuilder(sb, md, digestCharset); } private void appendMiddlePart(StringBuilder sb) { @@ -553,7 +566,7 @@ private void newResponse(MessageDigest md) { appendMiddlePart(sb); appendBase16(sb, ha2); - byte[] responseDigest = md5FromRecycledStringBuilder(sb, md); + byte[] responseDigest = digestFromRecycledStringBuilder(sb, md, digestCharset); response = toHexString(responseDigest); } } @@ -567,7 +580,9 @@ public Realm build() { // Avoid generating if (isNonEmpty(nonce)) { - MessageDigest md = pooledMd5MessageDigest(); + // Defensive: if algorithm is null, default to MD5 + String algo = (algorithm != null) ? algorithm : "MD5"; + MessageDigest md = getDigestInstance(algo); newCnonce(md); newResponse(md); } @@ -585,7 +600,7 @@ public Realm build() { cnonce, uri, usePreemptive, - charset, + (scheme == AuthScheme.DIGEST ? digestCharset : charset), ntlmDomain, ntlmHost, useAbsoluteURI, diff --git a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java index 4e2c4aed3..165b2b1bc 100644 --- a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java @@ -5,24 +5,33 @@ * and you may not use this file except in compliance with the Apache License Version 2.0. * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an + * Unless required by applicable law or agreed to in writing, software distributed under the Apache License Version 2.0 is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ package org.asynchttpclient.util; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import org.asynchttpclient.Realm; import org.asynchttpclient.Request; import org.asynchttpclient.ntlm.NtlmEngine; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.request.body.Body; +import org.asynchttpclient.request.body.generator.BodyGenerator; +import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator; +import org.asynchttpclient.request.body.generator.FileBodyGenerator; import org.asynchttpclient.spnego.SpnegoEngine; import org.asynchttpclient.spnego.SpnegoEngineException; import org.asynchttpclient.uri.Uri; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; import java.util.Base64; import java.util.List; @@ -34,6 +43,7 @@ public final class AuthenticatorUtils { public static final String NEGOTIATE = "Negotiate"; + private static final int MAX_AUTH_INT_BODY_SIZE = 10 * 1024 * 1024; private AuthenticatorUtils() { // Prevent outside initialization @@ -70,9 +80,7 @@ public static String computeRealmURI(Uri uri, boolean useAbsoluteURI, boolean om } private static String computeDigestAuthentication(Realm realm, Uri uri) { - String realmUri = computeRealmURI(uri, realm.isUseAbsoluteURI(), realm.isOmitQuery()); - StringBuilder builder = new StringBuilder().append("Digest "); append(builder, "username", realm.getPrincipal(), true); append(builder, "realm", realm.getRealmName(), true); @@ -81,25 +89,307 @@ private static String computeDigestAuthentication(Realm realm, Uri uri) { if (isNonEmpty(realm.getAlgorithm())) { append(builder, "algorithm", realm.getAlgorithm(), false); } - append(builder, "response", realm.getResponse(), true); - if (realm.getOpaque() != null) { append(builder, "opaque", realm.getOpaque(), true); } - + if (realm.getScheme() == Realm.AuthScheme.DIGEST && realm.getCharset() == StandardCharsets.UTF_8) { + append(builder, "charset", "UTF-8", false); + } if (realm.getQop() != null) { append(builder, "qop", realm.getQop(), false); - // nc and cnonce only sent if server sent qop append(builder, "nc", realm.getNc(), false); append(builder, "cnonce", realm.getCnonce(), true); } + // RFC7616: userhash parameter (optional, not implemented yet) builder.setLength(builder.length() - 2); // remove tailing ", " + Charset wireCs = (realm.getCharset() == StandardCharsets.UTF_8) + ? StandardCharsets.UTF_8 + : ISO_8859_1; + return new String(StringUtils.charSequence2Bytes(builder, wireCs), wireCs); + } + + /** + * Calculates the digest response value for HTTP Digest Authentication. + * This method computes HA1 and HA2 (including entity-body hash for auth-int). + * + * @param realm The authentication realm containing credentials and challenge info + * @param request The HTTP request (needed for method, uri, and body) + * @return The computed response hex string + * @throws UnsupportedOperationException if qop=auth-int but body cannot be hashed + */ + static String computeDigestResponse(Realm realm, Request request) { + String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5"; + String qop = realm.getQop() != null ? realm.getQop() : "auth"; + + String hashAlgorithm = algorithm.replace("-sess", ""); + Charset wireCharset = realm.getCharset() != null ? + realm.getCharset() : StandardCharsets.ISO_8859_1; + + // Calculate HA1 + String ha1 = calculateHA1(realm, algorithm); + + // Get request URI + Uri uri = request.getUri(); + String requestUri = uri.getPath() + + (uri.getQuery() != null ? "?" + uri.getQuery() : ""); + + // Calculate HA2 + String ha2; + if ("auth-int".equals(qop)) { + String bodyHash = computeBodyHash(request, realm); + ha2 = calculateHA2AuthInt(request, requestUri, bodyHash, hashAlgorithm, wireCharset); + } else { + // Regular auth: HA2 = H(method:uri) + String a2Plain = request.getMethod() + ":" + requestUri; + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(a2Plain.getBytes(wireCharset)); + ha2 = MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + // Build final response + String nc = realm.getNc() != null ? realm.getNc() : "00000001"; + String cnonce = realm.getCnonce(); + String nonce = realm.getNonce(); + + // response = H(HA1:nonce:nc:cnonce:qop:HA2) + String responseInput = ha1 + ":" + nonce + ":" + nc + ":" + + cnonce + ":" + qop + ":" + ha2; + + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(responseInput.getBytes(StandardCharsets.ISO_8859_1)); + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + /** + * Calculates the HA1 value for HTTP Digest Authentication. + * This method handles both regular and session-based HA1 calculations. + * + * @param realm The authentication realm containing credentials and challenge info + * @param algorithm The digest algorithm (e.g., "MD5", "MD5-sess") + * @return The computed HA1 hex string + */ + private static String calculateHA1(Realm realm, String algorithm) { + Charset wireCs = realm.getCharset() != null ? realm.getCharset() : StandardCharsets.ISO_8859_1; + String a1Base = realm.getPrincipal() + ':' + realm.getRealmName() + ':' + realm.getPassword(); + String hashAlgorithm = algorithm.replace("-sess", ""); + + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(a1Base.getBytes(wireCs)); + String ha1 = MessageDigestUtils.bytesToHex(md.digest()); + + + if (algorithm.endsWith("-sess")) { + // For -sess: HA1 = H(H(username:realm:password):nonce:cnonce) + String sessInput = ha1 + ":" + realm.getNonce() + ":" + realm.getCnonce(); + md.reset(); + md.update(sessInput.getBytes(StandardCharsets.ISO_8859_1)); + ha1 = MessageDigestUtils.bytesToHex(md.digest()); + } + + return ha1; + } finally { + md.reset(); + } + } + + /** + * Calculates the HA2 value for HTTP Digest Authentication. + * This method handles both auth and auth-int cases. + * + * @param request The HTTP request (needed for method, uri, and body) + * @param requestUri The request URI + * @param bodyHash The entity-body hash (for auth-int, can be empty for auth) + * @param hashAlgorithm The digest algorithm (e.g., "MD5") + * @param wireCs The charset used for wire encoding + * @return The computed HA2 hex string + */ + private static String calculateHA2AuthInt(Request request, String requestUri, String bodyHash, String hashAlgorithm, Charset wireCs) { + String a2Plain = request.getMethod() + ':' + requestUri + ':' + bodyHash; + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(a2Plain.getBytes(wireCs)); + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); // return clean to pool + } + } + + static String computeBodyHash(Request request, Realm realm) { + + if (request.getStringData() == null && + request.getByteData() == null && + request.getByteBufData() == null && + request.getByteBufferData() == null && + request.getBodyGenerator() == null) { + + // No body to hash, return hash of empty string + + String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5"; + String hashAlgorithm = algorithm.replace("-sess", ""); + + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5"; + String hashAlgorithm = algorithm.replace("-sess", ""); + Charset charset = resolveCharset(request, realm); + + + if (request.getStringData() != null) { + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(request.getStringData().getBytes(charset)); + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + if (request.getByteBufData() != null) { + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + ByteBuf buf = request.getByteBufData(); + int idx = buf.readerIndex(); + int len = buf.readableBytes(); + + byte[] tmp = new byte[len]; + buf.getBytes(idx, tmp); // copy once + md.update(tmp); + + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + + if (request.getByteBufferData() != null) { + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + ByteBuffer bb = request.getByteBufferData().asReadOnlyBuffer(); + bb.position(0); + md.update(bb); + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + if (request.getByteData() != null) { + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + try { + md.update(request.getByteData()); + return MessageDigestUtils.bytesToHex(md.digest()); + } finally { + md.reset(); + } + } + + // Handle BodyGenerator + if (request.getBodyGenerator() != null) { + return bufferAndHashBodyGenerator(request.getBodyGenerator(), hashAlgorithm); + } + + throw new IllegalStateException("Unexpected request body state"); - // FIXME isn't there a more efficient way? - return new String(StringUtils.charSequence2Bytes(builder, ISO_8859_1), StandardCharsets.UTF_8); } + /** + * Resolve the charset used to read / hash a request body. + * Order of precedence: + * 1) request.getCharset() – per-request override + * 2) realm.getCharset() – negotiated via RFC 7616 (e.g. UTF-8) + * 3) ISO-8859-1 – RFC default + */ + private static Charset resolveCharset(Request request, Realm realm) { + Charset cs = request.getCharset(); + if (cs != null) { + return cs; + } + cs = realm.getCharset(); + return (cs != null) ? cs : StandardCharsets.ISO_8859_1; + } + + /** + * Buffers the body from the given BodyGenerator and computes its hash. + * This is used for auth-int where the body needs to be hashed. + * + * @param gen The BodyGenerator to read from + * @param hashAlgorithm The hash algorithm to use (e.g., "MD5", "SHA-256") + * @return The hex string of the computed hash + * @throws UnsupportedOperationException if the body is too large or unsupported type + */ + private static String bufferAndHashBodyGenerator(BodyGenerator gen, String hashAlgorithm) { + MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm); + // Size guard + if (gen instanceof ByteArrayBodyGenerator) { + ByteArrayBodyGenerator bag = (ByteArrayBodyGenerator) gen; + + long size = bag.createBody().getContentLength(); + if (size > MAX_AUTH_INT_BODY_SIZE) { + throw new UnsupportedOperationException("auth-int not supported for ByteArrayBodyGenerator >10 MB"); + } + } else if (gen instanceof FileBodyGenerator) { + FileBodyGenerator fg = (FileBodyGenerator) gen; + + long fileSize = fg.getFile().length(); + if (fileSize > MAX_AUTH_INT_BODY_SIZE) { + throw new UnsupportedOperationException("auth-int not supported for files > 10 MB"); + } + try { + byte[] bytes = Files.readAllBytes(fg.getFile().toPath()); // may throw IOException + md.update(bytes); + return MessageDigestUtils.bytesToHex(md.digest()); + } catch (IOException ioe) { + throw new RuntimeException("Failed to read file for auth-int hash", ioe); + } + } else { + throw new UnsupportedOperationException("auth-int currently supports only ByteArrayBodyGenerator and FileBodyGenerator"); + } + + ByteBuf tmp = Unpooled.buffer(8192); + + try (Body body = gen.createBody()) { + Body.BodyState state; + while ((state = body.transferTo(tmp)) != Body.BodyState.STOP) { + if (state == Body.BodyState.SUSPEND) { + continue; // nothing new yet + } + int len = tmp.writerIndex(); + byte[] buf = new byte[len]; + tmp.getBytes(0, buf); + md.update(buf); + tmp.clear(); + } + return MessageDigestUtils.bytesToHex(md.digest()); + + } catch (IOException ioe) { + throw new RuntimeException("Failed to hash request body", ioe); + } finally { + try { + md.reset(); + } finally { + tmp.release(); + } + } + } + + private static void append(StringBuilder builder, String name, @Nullable String value, boolean quoted) { builder.append(name).append('='); if (quoted) { @@ -143,10 +433,16 @@ private static void append(StringBuilder builder, String name, @Nullable String if (isNonEmpty(proxyRealm.getNonce())) { // update realm with request information final Uri uri = request.getUri(); - proxyRealm = realm(proxyRealm) + Realm.Builder realmBuilder = realm(proxyRealm) .setUri(uri) - .setMethodName(request.getMethod()) - .build(); + .setMethodName(request.getMethod()); + + if ("auth-int".equals(proxyRealm.getQop())) { + String response = computeDigestResponse(proxyRealm, request); + realmBuilder.setResponse(response); + } + + proxyRealm = realmBuilder.build(); proxyAuthorization = computeDigestAuthentication(proxyRealm, uri); } break; @@ -218,10 +514,15 @@ private static void append(StringBuilder builder, String name, @Nullable String if (isNonEmpty(realm.getNonce())) { // update realm with request information final Uri uri = request.getUri(); - realm = realm(realm) + Realm.Builder realmBuilder = realm(realm) .setUri(uri) - .setMethodName(request.getMethod()) - .build(); + .setMethodName(request.getMethod()); + if ("auth-int".equals(realm.getQop())) { + String response = computeDigestResponse(realmBuilder.build(), request); + realmBuilder.setResponse(response); + } + + realm = realmBuilder.build(); authorizationHeader = computeDigestAuthentication(realm, uri); } break; diff --git a/client/src/main/java/org/asynchttpclient/util/MessageDigestUtils.java b/client/src/main/java/org/asynchttpclient/util/MessageDigestUtils.java index c60e24238..9a518dfe5 100644 --- a/client/src/main/java/org/asynchttpclient/util/MessageDigestUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/MessageDigestUtils.java @@ -18,6 +18,13 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +/** + * Thread-safety: Each digest is kept in a ThreadLocal. This + * class is intended for use on long-lived threads (e.g., Netty event loops). + * If you call it from a short-lived or unbounded thread pool, you may + * inadvertently retain one MessageDigest instance per thread, leading + * to memory leaks. + */ public final class MessageDigestUtils { private static final ThreadLocal MD5_MESSAGE_DIGESTS = ThreadLocal.withInitial(() -> { @@ -36,19 +43,112 @@ public final class MessageDigestUtils { } }); + private static final ThreadLocal SHA256_MESSAGE_DIGESTS = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new InternalError("SHA-256 not supported on this platform"); + } + }); + + private static final ThreadLocal SHA512_256_MESSAGE_DIGESTS = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA-512/256"); + } catch (NoSuchAlgorithmException e) { + throw new InternalError("SHA-512/256 not supported on this platform"); + } + }); + private MessageDigestUtils() { // Prevent outside initialization } - public static MessageDigest pooledMd5MessageDigest() { - MessageDigest md = MD5_MESSAGE_DIGESTS.get(); + /** + * Returns a pooled MessageDigest instance for the given algorithm name. + * Supported: "MD5", "SHA-1", "SHA-256", "SHA-512/256" (and aliases). + * The returned instance is thread-local and reset before use. + * + * @param algorithm the algorithm name (e.g., "MD5", "SHA-256", "SHA-512/256") + * @return a reset MessageDigest instance for the algorithm + * @throws IllegalArgumentException if the algorithm is not supported + */ + public static MessageDigest pooledMessageDigest(String algorithm) { + String alg = algorithm.replace("_", "-").toUpperCase(); + MessageDigest md; + if ("SHA-512-256".equals(alg)) alg = "SHA-512/256"; + switch (alg) { + case "MD5": + md = MD5_MESSAGE_DIGESTS.get(); + break; + case "SHA1": + case "SHA-1": + md = SHA1_MESSAGE_DIGESTS.get(); + break; + case "SHA-256": + md = SHA256_MESSAGE_DIGESTS.get(); + break; + case "SHA-512/256": + md = SHA512_256_MESSAGE_DIGESTS.get(); + break; + default: + try { + md = MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("Unsupported digest algorithm: " + algorithm, e); + } + } md.reset(); return md; } + /** + * Converts a byte array to a lower-case hexadecimal String. + * Locale-safe and allocation-free except for the final char[] → String copy. + * + * @param bytes the byte array to convert (must not be null) + * @return 2×length lower-case hex string + * @throws IllegalArgumentException if {@code bytes} is null + */ + public static String bytesToHex(byte[] bytes) { + if (bytes == null) { + throw new IllegalArgumentException("bytes == null"); + } + final char[] HEX = "0123456789abcdef".toCharArray(); + char[] out = new char[bytes.length << 1]; + + for (int i = 0, j = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + out[j++] = HEX[v >>> 4]; + out[j++] = HEX[v & 0x0F]; + } + return new String(out); + } + + /** + * @return a pooled, reset MessageDigest for MD5 + */ + public static MessageDigest pooledMd5MessageDigest() { + return pooledMessageDigest("MD5"); + } + + /** + * @return a pooled, reset MessageDigest for SHA-1 + */ public static MessageDigest pooledSha1MessageDigest() { - MessageDigest md = SHA1_MESSAGE_DIGESTS.get(); - md.reset(); - return md; + return pooledMessageDigest("SHA-1"); + } + + /** + * @return a pooled, reset MessageDigest for SHA-256 + */ + public static MessageDigest pooledSha256MessageDigest() { + return pooledMessageDigest("SHA-256"); + } + + /** + * @return a pooled, reset MessageDigest for SHA-512/256 + */ + public static MessageDigest pooledSha512_256MessageDigest() { + return pooledMessageDigest("SHA-512/256"); } } diff --git a/client/src/test/java/org/asynchttpclient/AuthTimeoutTest.java b/client/src/test/java/org/asynchttpclient/AuthTimeoutTest.java index e23328d7a..2ae7ea279 100644 --- a/client/src/test/java/org/asynchttpclient/AuthTimeoutTest.java +++ b/client/src/test/java/org/asynchttpclient/AuthTimeoutTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; +import org.asynchttpclient.test.ExtendedDigestAuthenticator; import java.io.IOException; import java.io.OutputStream; @@ -39,7 +40,7 @@ import static org.asynchttpclient.test.TestUtils.ADMIN; import static org.asynchttpclient.test.TestUtils.USER; import static org.asynchttpclient.test.TestUtils.addBasicAuthHandler; -import static org.asynchttpclient.test.TestUtils.addDigestAuthHandler; +// import static org.asynchttpclient.test.TestUtils.addDigestAuthHandler; import static org.asynchttpclient.test.TestUtils.addHttpConnector; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -63,7 +64,8 @@ public void setUpGlobal() throws Exception { server2 = new Server(); ServerConnector connector2 = addHttpConnector(server2); - addDigestAuthHandler(server2, configureHandler()); + // Use DigestAuthHandler for server2 (digest tests), otherwise use default handler + server2.setHandler(new DigestAuthHandler()); server2.start(); port2 = connector2.getLocalPort(); @@ -181,6 +183,51 @@ public AbstractHandler configureHandler() throws Exception { return new IncompleteResponseHandler(); } + // DigestAuthHandler for Digest tests (MD5 only, as in old Jetty default) + private static class DigestAuthHandler extends AbstractHandler { + private final String realm = "MyRealm"; + private final String user = USER; + private final String password = ADMIN; + private final ExtendedDigestAuthenticator authenticator = new ExtendedDigestAuthenticator("MD5"); + private final String nonce = ExtendedDigestAuthenticator.newNonce(); + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + String authz = request.getHeader("Authorization"); + if (authz == null || !authz.startsWith("Digest ")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, false)); + response.getOutputStream().close(); + return; + } + String credentials = authz.substring("Digest ".length()); + if (!user.equals(ExtendedDigestAuthenticator.parseCredentials(credentials).get("username"))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true)); + response.getOutputStream().close(); + return; + } + boolean ok = ExtendedDigestAuthenticator.validateDigest(request.getMethod(), credentials, password); + if (!ok) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true)); + response.getOutputStream().close(); + return; + } + // Success: simulate incomplete response for timeout + response.setStatus(200); + OutputStream out = response.getOutputStream(); + response.setIntHeader(CONTENT_LENGTH.toString(), 1000); + out.write(0); + out.flush(); + try { + Thread.sleep(LONG_FUTURE_TIMEOUT + 100); + } catch (InterruptedException e) { + // + } + } + } + private static class IncompleteResponseHandler extends AbstractHandler { @Override diff --git a/client/src/test/java/org/asynchttpclient/DigestAuthTest.java b/client/src/test/java/org/asynchttpclient/DigestAuthTest.java index d847396bd..8bdf56c68 100644 --- a/client/src/test/java/org/asynchttpclient/DigestAuthTest.java +++ b/client/src/test/java/org/asynchttpclient/DigestAuthTest.java @@ -5,8 +5,7 @@ * and you may not use this file except in compliance with the Apache License Version 2.0. * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an + * Unless required by applicable law or agreed to in writing, software distributed under the Apache License Version 2.0 is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ @@ -16,6 +15,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.asynchttpclient.test.ExtendedDigestAuthenticator; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; @@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach; import java.io.IOException; +import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -30,7 +31,6 @@ import static org.asynchttpclient.Dsl.digestAuthRealm; import static org.asynchttpclient.test.TestUtils.ADMIN; import static org.asynchttpclient.test.TestUtils.USER; -import static org.asynchttpclient.test.TestUtils.addDigestAuthHandler; import static org.asynchttpclient.test.TestUtils.addHttpConnector; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -42,7 +42,16 @@ public class DigestAuthTest extends AbstractBasicTest { public void setUpGlobal() throws Exception { server = new Server(); ServerConnector connector = addHttpConnector(server); - addDigestAuthHandler(server, configureHandler()); + String algorithm = null; + String currentTest = System.getProperty("test.name"); + if (currentTest != null) { + if (currentTest.contains("Sha256")) { + algorithm = "SHA-256"; + } else if (currentTest.contains("Sha512_256")) { + algorithm = "SHA-512-256"; + } + } + server.setHandler(new DigestAuthHandler(algorithm)); server.start(); port1 = connector.getLocalPort(); logger.info("Local HTTP server started successfully"); @@ -91,6 +100,87 @@ public void digestAuthNegativeTest() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void digestAuthSha256Test() throws Exception { + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(digestAuthRealm(USER, ADMIN) + .setRealmName("MyRealm") + .setAlgorithm("SHA-256") + .build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(resp.getStatusCode(), HttpServletResponse.SC_OK); + assertNotNull(resp.getHeader("X-Auth")); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void digestAuthSha512_256Test() throws Exception { + try (AsyncHttpClient client = asyncHttpClient()) { + Future f = client.prepareGet("http://localhost:" + port1 + '/') + .setRealm(digestAuthRealm(USER, ADMIN) + .setRealmName("MyRealm") + .setAlgorithm("SHA-512-256") + .build()) + .execute(); + Response resp = f.get(60, TimeUnit.SECONDS); + assertNotNull(resp); + assertEquals(resp.getStatusCode(), HttpServletResponse.SC_OK); + assertNotNull(resp.getHeader("X-Auth")); + } + } + + private static class DigestAuthHandler extends AbstractHandler { + private final String realm = "MyRealm"; + private final String user = USER; + private final String password = ADMIN; + private final ExtendedDigestAuthenticator authenticator; + private final String nonce; + private final String algorithm; + + DigestAuthHandler(String algorithm) { + this.algorithm = algorithm; + authenticator = new ExtendedDigestAuthenticator(algorithm); + nonce = ExtendedDigestAuthenticator.newNonce(); + } + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + String authz = request.getHeader("Authorization"); + if (authz == null || !authz.startsWith("Digest ")) { + // Challenge + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, false)); + response.getOutputStream().close(); + return; + } + // Validate + String credentials = authz.substring("Digest ".length()); + Map params = ExtendedDigestAuthenticator.parseCredentials(credentials); + String username = params.get("username"); + if (!user.equals(username)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true)); + response.getOutputStream().close(); + return; + } + boolean ok = ExtendedDigestAuthenticator.validateDigest(request.getMethod(), credentials, password); + if (!ok) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true)); + response.getOutputStream().close(); + return; + } + // Success + response.addHeader("X-Auth", authz); + response.setStatus(HttpServletResponse.SC_OK); + response.getOutputStream().flush(); + response.getOutputStream().close(); + } + } + private static class SimpleHandler extends AbstractHandler { @Override diff --git a/client/src/test/java/org/asynchttpclient/test/ExtendedDigestAuthenticator.java b/client/src/test/java/org/asynchttpclient/test/ExtendedDigestAuthenticator.java new file mode 100644 index 000000000..94735454e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/test/ExtendedDigestAuthenticator.java @@ -0,0 +1,145 @@ +package org.asynchttpclient.test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +/** + * Pure Java DigestAuthenticator for testing MD5, SHA-256, and SHA-512-256. + */ +public class ExtendedDigestAuthenticator { + private final String advertisedAlgorithm; + + public ExtendedDigestAuthenticator() { + this(null); + } + + public ExtendedDigestAuthenticator(String advertisedAlgorithm) { + this.advertisedAlgorithm = advertisedAlgorithm; + } + + public String getAdvertisedAlgorithm() { + return findAlgorithm(advertisedAlgorithm); + } + + public static String findAlgorithm(String algorithm) { + if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "MD5-sess".equalsIgnoreCase(algorithm)) { + return "MD5"; + } else if ("SHA-256".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm)) { + return "SHA-256"; + } else if ("SHA-512-256".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) { + return "SHA-512-256"; + } else { + return null; + } + } + + public static MessageDigest getMessageDigest(String algorithm) throws NoSuchAlgorithmException { + if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "MD5-sess".equalsIgnoreCase(algorithm)) { + return MessageDigest.getInstance("MD5"); + } else if ("SHA-256".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm)) { + return MessageDigest.getInstance("SHA-256"); + } else if ("SHA-512-256".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) { + return MessageDigest.getInstance("SHA-512/256"); + } else { + throw new NoSuchAlgorithmException("Unsupported digest algorithm: " + algorithm); + } + } + + public static String newNonce() { + byte[] nonceBytes = new byte[16]; + new Random().nextBytes(nonceBytes); + return Base64.getEncoder().encodeToString(nonceBytes); + } + + public String createAuthenticateHeader(String realm, String nonce, boolean stale) { + StringBuilder header = new StringBuilder(128); + header.append("Digest realm=\"").append(realm).append('"'); + header.append(", nonce=\"").append(nonce).append('"'); + String algorithm = getAdvertisedAlgorithm(); + if (algorithm != null) { + header.append(", algorithm=").append(algorithm); + } + header.append(", qop=\"auth\""); + if (stale) { + header.append(", stale=true"); + } + return header.toString(); + } + + /** + * Validate a Digest response from the client. + * @param method HTTP method + * @param credentials The Authorization header value (without "Digest ") + * @param password The user's password + * @return true if valid, false otherwise + */ + public static boolean validateDigest(String method, String credentials, String password) { + Map params = parseCredentials(credentials); + String username = params.get("username"); + String realm = params.get("realm"); + String nonce = params.get("nonce"); + String uri = params.get("uri"); + String response = params.get("response"); + String qop = params.get("qop"); + String nc = params.get("nc"); + String cnonce = params.get("cnonce"); + String algorithm = findAlgorithm(params.get("algorithm")); + + if (algorithm == null) { + algorithm = "MD5"; + } + + try { + MessageDigest md = getMessageDigest(algorithm); + String a1 = username + ':' + realm + ':' + password; + byte[] ha1 = md.digest(a1.getBytes(StandardCharsets.ISO_8859_1)); + + String ha1Hex = toHexString(ha1); + String a2 = method + ':' + uri; + byte[] ha2 = md.digest(a2.getBytes(StandardCharsets.ISO_8859_1)); + + String ha2Hex = toHexString(ha2); + String kd; + if (qop != null && !qop.isEmpty()) { + kd = ha1Hex + ':' + nonce + ':' + nc + ':' + cnonce + ':' + qop + ':' + ha2Hex; + } else { + kd = ha1Hex + ':' + nonce + ':' + ha2Hex; + } + + String expectedResponse = toHexString(md.digest(kd.getBytes(StandardCharsets.ISO_8859_1))); + return expectedResponse.equalsIgnoreCase(response); + } catch (Exception e) { + return false; + } + } + + public static Map parseCredentials(String credentials) { + Map map = new HashMap<>(); + String[] parts = credentials.split(","); + for (String part : parts) { + int idx = part.indexOf('='); + if (idx > 0) { + String key = part.substring(0, idx).trim(); + String value = part.substring(idx + 1).trim(); + if (value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + map.put(key, value); + } + } + return map; + } + + private static String toHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/client/src/test/java/org/asynchttpclient/test/TestUtils.java b/client/src/test/java/org/asynchttpclient/test/TestUtils.java index 499562824..313d71349 100644 --- a/client/src/test/java/org/asynchttpclient/test/TestUtils.java +++ b/client/src/test/java/org/asynchttpclient/test/TestUtils.java @@ -31,7 +31,6 @@ import org.eclipse.jetty.security.HashLoginService; import org.eclipse.jetty.security.LoginService; import org.eclipse.jetty.security.authentication.BasicAuthenticator; -import org.eclipse.jetty.security.authentication.DigestAuthenticator; import org.eclipse.jetty.security.authentication.LoginAuthenticator; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.HttpConfiguration; @@ -180,9 +179,7 @@ public static void addBasicAuthHandler(Server server, Handler handler) { addAuthHandler(server, Constraint.__BASIC_AUTH, new BasicAuthenticator(), handler); } - public static void addDigestAuthHandler(Server server, Handler handler) { - addAuthHandler(server, Constraint.__DIGEST_AUTH, new DigestAuthenticator(), handler); - } + // Removed obsolete addDigestAuthHandler and related Jetty digest code. private static void addAuthHandler(Server server, String auth, LoginAuthenticator authenticator, Handler handler) { server.addBean(LOGIN_SERVICE); diff --git a/client/src/test/java/org/asynchttpclient/util/AuthenticatorUtilsTest.java b/client/src/test/java/org/asynchttpclient/util/AuthenticatorUtilsTest.java new file mode 100644 index 000000000..15f8bd59d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/util/AuthenticatorUtilsTest.java @@ -0,0 +1,386 @@ +package org.asynchttpclient.util; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.asynchttpclient.Realm; +import org.asynchttpclient.Request; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator; +import org.asynchttpclient.request.body.generator.FileBodyGenerator; +import org.asynchttpclient.request.body.generator.InputStreamBodyGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AuthenticatorUtilsTest { + @Test + void computeBodyHashEmptyBody() throws Exception { + Request request = new RequestBuilder("GET") + .setUrl("http://example.com/api/users") + .build(); + + Realm realm = new Realm.Builder("user", "pass") + .setAlgorithm("MD5") + .setScheme(Realm.AuthScheme.DIGEST) + .build(); + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + String expectedHash = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest()); + assertEquals(expectedHash, bodyHash); + } + + @Test + void computeBodyHashStringBody_DefaultCharset() throws Exception { + String Body = "Hello World"; + + Request request = new RequestBuilder("POST") + .setUrl("http://example.com/api/users") + .setBody(Body) + .build(); + + Realm realm = new Realm.Builder("user", "pass") + .setAlgorithm("MD5") + .setScheme(Realm.AuthScheme.DIGEST) + .build(); + + String BodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + String expectedHash = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest(Body.getBytes(StandardCharsets.ISO_8859_1))); + + assertEquals(expectedHash, BodyHash); + } + + @Test + void computeBodyHashStringBody_UTF8() throws Exception { + String Body = "Hello 世界"; //chinese + + Request request = new RequestBuilder("POST") + .setUrl("http://example.com/api/users") + .setBody(Body) + .setCharset(StandardCharsets.UTF_8) + .build(); + + Realm realm = new Realm.Builder("user", "pass") + .setAlgorithm("MD5") + .setScheme(Realm.AuthScheme.DIGEST) + .build(); + + String BodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + String expectedHash = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest(Body.getBytes(StandardCharsets.UTF_8))); + + assertEquals(expectedHash, BodyHash); + } + + @Test + void computeBodyHashByteArrayBodyGenerator1() throws Exception { + byte[] body = { 0x01, 0x02, 0x03, 0x04, 0x05 }; + + Request request = new RequestBuilder("POST") + .setUrl("http://example.com/api") + .setBody(body) // builder will wrap this in a ByteArrayBodyGenerator + .build(); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest(body) + ); + assertEquals(expected, bodyHash); + } + + + @Test + void computeBodyHashByteArrayBodyGenerator() throws Exception { + byte[] body = { 0x01, 0x02, 0x03, 0x04, 0x05 }; + + ByteArrayBodyGenerator generator = new ByteArrayBodyGenerator(body); + + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + // all other getters return null + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest(body) + ); + assertEquals(expected, bodyHash); + } + + + @Test + void computeBodyHashByteBuf() throws Exception { + ByteBuf buf = Unpooled.copiedBuffer("ByteBuf Test", StandardCharsets.UTF_8); + buf.readerIndex(4); // advance reader → we should hash only "Buf Test" + + // Mock a Request whose body lives in that ByteBuf + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getByteBufData()).thenReturn(buf); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getBodyGenerator()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + try { + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5") + .digest("Buf Test".getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(expected, bodyHash, "ByteBuf branch produced wrong digest"); + assertEquals(4, buf.readerIndex(), "Reader index must stay unchanged"); + + } finally { + buf.release(); + } + } + + @Test + void computeBodyHashByteBuffer() throws Exception { + // Create ByteBuffer payload + ByteBuffer bb = ByteBuffer.wrap("ByteBuffer Test".getBytes(StandardCharsets.UTF_8)); + bb.position(5); // advance position → helper must hash full content + + // Mock a Request whose body lives in that ByteBuffer + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getByteBufferData()).thenReturn(bb); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getBodyGenerator()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + // Expected digest of "ByteBuffer Test" (full content) + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5") + .digest("ByteBuffer Test".getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(expected, bodyHash, "ByteBuffer branch produced wrong digest"); + assertEquals(5, bb.position(), "ByteBuffer position must stay unchanged"); + } + + @TempDir Path tempDir; // JUnit-5-managed temporary folder + + @Test + void computeBodyHashFileBodyGenerator() throws Exception { + String content = "File content for testing"; + Path file = tempDir.resolve("test.dat"); + Files.writeString(file, content, StandardCharsets.UTF_8); + + FileBodyGenerator generator = new FileBodyGenerator(file.toFile()); + + // Stub Request: only BodyGenerator path is populated + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + // Reference digest + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5") + .digest(content.getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(expected, bodyHash); + } + + @Test + void computeBodyHashMultiChunkByteArray() throws Exception { + // forces three chunks (8 K + 8 K + 4 K) + byte[] data = new byte[20 * 1024]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (i & 0xFF); + } + + ByteArrayBodyGenerator generator = new ByteArrayBodyGenerator(data); + + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + String hash1 = AuthenticatorUtils.computeBodyHash(request, realm); + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("MD5").digest(data) + ); + assertEquals(expected, hash1, "Multi-chunk digest mismatch"); + + String hash2 = AuthenticatorUtils.computeBodyHash(request, realm); + assertEquals(hash1, hash2, "Digest should be reproducible"); + assertEquals(32, hash1.length(), "MD5 hex length"); + } + + @Test + void byteArrayGeneratorTooLargeThrows() { + byte[] oversized = new byte[11 * 1024 * 1024]; // 11 MB + ByteArrayBodyGenerator generator = new ByteArrayBodyGenerator(oversized); + + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + assertThrows(UnsupportedOperationException.class, () -> AuthenticatorUtils.computeBodyHash(request, realm)); + } + + @Test + void fileBodyGeneratorTooLargeThrows() throws Exception { + // create an 11 MB temp file + Path bigFile = tempDir.resolve("big.bin"); + try (OutputStream os = Files.newOutputStream(bigFile)) { + byte[] chunk = new byte[1024 * 1024]; // 1 MB zero-block + for (int i = 0; i < 11; i++) { + os.write(chunk); + } + } + + FileBodyGenerator generator = new FileBodyGenerator(bigFile.toFile()); + + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + assertThrows(UnsupportedOperationException.class, () -> AuthenticatorUtils.computeBodyHash(request, realm)); + } + + @Test + void unsupportedBodyGeneratorThrows() { + InputStreamBodyGenerator generator = new InputStreamBodyGenerator(new ByteArrayInputStream(new byte[10])); + + Request request = mock(Request.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getBodyGenerator()).thenReturn(generator); + when(request.getStringData()).thenReturn(null); + when(request.getByteData()).thenReturn(null); + when(request.getByteBufData()).thenReturn(null); + when(request.getByteBufferData()).thenReturn(null); + when(request.getCharset()).thenReturn(null); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("MD5") + .build(); + + assertThrows(UnsupportedOperationException.class, + () -> AuthenticatorUtils.computeBodyHash(request, realm)); + } + + @Test + void computeBodyHashSHA256() throws Exception { + String body = "Test SHA-256"; + + Request request = new RequestBuilder("POST") + .setUrl("http://example.com/api") + .setBody(body) + .build(); + + Realm realm = new Realm.Builder("user", "pass") + .setScheme(Realm.AuthScheme.DIGEST) + .setAlgorithm("SHA-256") + .build(); + + String bodyHash = AuthenticatorUtils.computeBodyHash(request, realm); + + String expected = MessageDigestUtils.bytesToHex( + MessageDigest.getInstance("SHA-256") + .digest(body.getBytes(StandardCharsets.ISO_8859_1))); + assertEquals(expected, bodyHash); + assertEquals(64, bodyHash.length()); + } + + @Test + void bytesToHexWorks() { + byte[] input = {0x01, 0x23, 0x45, 0x67, (byte) 0x89, (byte) 0xAB, (byte) 0xCD, (byte) 0xEF}; + String hex = MessageDigestUtils.bytesToHex(input); + assertEquals("0123456789abcdef", hex); + } + + @Test + void bytesToHexNullThrows() { + assertThrows(IllegalArgumentException.class, () -> MessageDigestUtils.bytesToHex(null)); + } +}