From 3eaf9bf5c0a16f6456890d23ceb027f39c4113e0 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sun, 3 Dec 2023 21:53:36 +0100 Subject: [PATCH] Implement Support for Userhash Parameter in Digest Authentication as per RFC 7616 (#509) This commit introduces support for the userhash parameter in Digest Authentication, conforming to the specifications outlined in RFC 7616. The userhash parameter enhances security by allowing the client to hash the username before transmission, thereby protecting the username during transport. This implementation ensures that when the server indicates support for username hashing (userhash=true), the client correctly calculates and includes the hashed username in the Authorization header field, adhering to the protocol defined in RFC 7616 for enhanced security in HTTP Digest Access Authentication. --- .../client5/http/impl/auth/DigestScheme.java | 42 +++++++++- .../http/impl/auth/TestDigestScheme.java | 81 ++++++++++++++++++- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java index 32cd437dd..ad4502afc 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java @@ -113,6 +113,24 @@ public class DigestScheme implements AuthScheme, Serializable { private boolean complete; private transient ByteArrayBuilder buffer; + /** + * Flag indicating whether username hashing is supported. + *

+ * This flag is used to determine if the server supports hashing of the username + * as part of the Digest Access Authentication process. When set to {@code true}, + * the client is expected to hash the username using the same algorithm used for + * hashing the credentials. This is in accordance with Section 3.4.4 of RFC 7616. + *

+ *

+ * The default value is {@code false}, indicating that username hashing is not + * supported. If the server requires username hashing (indicated by the + * {@code userhash} parameter in the a header set to {@code true}), + * this flag should be set to {@code true} to comply with the server's requirements. + *

+ */ + private boolean userhashSupported = false; + + private String lastNonce; private long nounceCount; private String cnonce; @@ -177,6 +195,10 @@ public class DigestScheme implements AuthScheme, Serializable { if (this.paramMap.isEmpty()) { throw new MalformedChallengeException("Missing digest auth parameters"); } + + final String userHashValue = this.paramMap.get("userhash"); + this.userhashSupported = "true".equalsIgnoreCase(userHashValue); + this.complete = true; } @@ -319,6 +341,15 @@ public class DigestScheme implements AuthScheme, Serializable { } buffer.charset(charset); + + String username = credentials.getUserName(); + + if (this.userhashSupported) { + final String usernameRealm = username + ":" + realm; + final byte[] hashedBytes = digester.digest(usernameRealm.getBytes(StandardCharsets.UTF_8)); + username = formatHex(hashedBytes); // Convert to hex string + } + a1 = null; a2 = null; // 3.2.2.2: Calculating digest @@ -328,13 +359,13 @@ public class DigestScheme implements AuthScheme, Serializable { // ":" unq(cnonce-value) // calculated one per session - buffer.append(credentials.getUserName()).append(":").append(realm).append(":").append(credentials.getUserPassword()); + buffer.append(username).append(":").append(credentials.getUserPassword()); final String checksum = formatHex(digester.digest(this.buffer.toByteArray())); buffer.reset(); buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce); } else { // unq(username-value) ":" unq(realm-value) ":" passwd - buffer.append(credentials.getUserName()).append(":").append(realm).append(":").append(credentials.getUserPassword()); + buffer.append(username).append(":").append(credentials.getUserPassword()); } a1 = buffer.toByteArray(); @@ -395,7 +426,7 @@ public class DigestScheme implements AuthScheme, Serializable { buffer.append(StandardAuthScheme.DIGEST + " "); final List params = new ArrayList<>(20); - params.add(new BasicNameValuePair("username", credentials.getUserName())); + params.add(new BasicNameValuePair("username", username)); params.add(new BasicNameValuePair("realm", realm)); params.add(new BasicNameValuePair("nonce", nonce)); params.add(new BasicNameValuePair("uri", uri)); @@ -413,6 +444,10 @@ public class DigestScheme implements AuthScheme, Serializable { params.add(new BasicNameValuePair("opaque", opaque)); } + if (this.userhashSupported) { + params.add(new BasicNameValuePair("userhash", "true")); + } + for (int i = 0; i < params.size(); i++) { final BasicNameValuePair param = params.get(i); if (i > 0) { @@ -494,5 +529,4 @@ public class DigestScheme implements AuthScheme, Serializable { public String toString() { return getName() + this.paramMap; } - } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java index 3f05886cc..382212c83 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java @@ -114,7 +114,7 @@ public class TestDigestScheme { Assertions.assertEquals("realm1", table.get("realm")); Assertions.assertEquals("/", table.get("uri")); Assertions.assertEquals("f2a3f18799759d4f1a1c068b92b573cb", table.get("nonce")); - Assertions.assertEquals("e95a7ddf37c2eab009568b1ed134f89a", table.get("response")); + Assertions.assertEquals("da46708e64b8380f1c5afa63e8ccd586", table.get("response")); } @Test @@ -138,7 +138,7 @@ public class TestDigestScheme { Assertions.assertEquals("realm1", table.get("realm")); Assertions.assertEquals("/", table.get("uri")); Assertions.assertEquals("f2a3f18799759d4f1a1c068b92b573cb", table.get("nonce")); - Assertions.assertEquals("e95a7ddf37c2eab009568b1ed134f89a", table.get("response")); + Assertions.assertEquals("da46708e64b8380f1c5afa63e8ccd586", table.get("response")); } @Test @@ -184,7 +184,7 @@ public class TestDigestScheme { Assertions.assertEquals("realm1", table.get("realm")); Assertions.assertEquals("/", table.get("uri")); Assertions.assertEquals("f2a3f18799759d4f1a1c068b92b573cb", table.get("nonce")); - Assertions.assertEquals("8769e82e4e28ecc040b969562b9050580c6d186d", table.get("response")); + Assertions.assertEquals("aa400f3841ebbf39469d9be939a37b86258bd289", table.get("response")); } @Test @@ -208,7 +208,7 @@ public class TestDigestScheme { Assertions.assertEquals("realm1", table.get("realm")); Assertions.assertEquals("/?param=value", table.get("uri")); Assertions.assertEquals("f2a3f18799759d4f1a1c068b92b573cb", table.get("nonce")); - Assertions.assertEquals("a847f58f5fef0bc087bcb9c3eb30e042", table.get("response")); + Assertions.assertEquals("c15c577938f7f1228cdb6e8ca51b9140", table.get("response")); } @Test @@ -746,4 +746,77 @@ public class TestDigestScheme { Assertions.assertEquals(digestScheme.getCnonce(), authScheme.getCnonce()); } + + @Test + public void testDigestAuthenticationWithUserHash() throws Exception { + final HttpRequest request = new BasicHttpRequest("Simple", "/"); + final HttpHost host = new HttpHost("somehost", 80); + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "realm1", null), "username", "password".toCharArray()) + .build(); + + // Include userhash in the challenge + final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", userhash=true"; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + final String authResponse = authscheme.generateAuthResponse(host, request, null); + + final Map table = parseAuthResponse(authResponse); + + // Generate expected userhash + final MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(("username:realm1").getBytes(StandardCharsets.UTF_8)); + final String expectedUserhash = bytesToHex(md.digest()); + + Assertions.assertEquals(expectedUserhash, table.get("username")); + Assertions.assertEquals("realm1", table.get("realm")); + Assertions.assertEquals("/", table.get("uri")); + Assertions.assertEquals("f2a3f18799759d4f1a1c068b92b573cb", table.get("nonce")); + Assertions.assertEquals("75f7ede943dc401264d236546e49c1df", table.get("response")); + } + + private static String bytesToHex(final byte[] bytes) { + final StringBuilder hexString = new StringBuilder(); + for (final byte b : bytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + + @Test + public void testDigestAuthenticationWithQuotedStringsAndWhitespace() throws Exception { + final HttpRequest request = new BasicHttpRequest("Simple", "/"); + final HttpHost host = new HttpHost("somehost", 80); + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(host, "\"myhost@example.com\"", null), "\"Mufasa\"", "\"Circle Of Life\"".toCharArray()) + .build(); + + // Include userhash in the challenge + final String challenge = StandardAuthScheme.DIGEST + " realm=\"\\\"myhost@example.com\\\"\", nonce=\"f2a3f18799759d4f1a1c068b92b573cb\", userhash=true"; + final AuthChallenge authChallenge = parse(challenge); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(authChallenge, null); + + Assertions.assertTrue(authscheme.isResponseReady(host, credentialsProvider, null)); + + final String authResponse = authscheme.generateAuthResponse(host, request, null); + + final Map table = parseAuthResponse(authResponse); + + // Generate expected A1 hash + final MessageDigest md = MessageDigest.getInstance("MD5"); + final String a1 = "Mufasa:myhost@example.com:Circle Of Life"; // Note: quotes removed and internal whitespace preserved + md.update(a1.getBytes(StandardCharsets.UTF_8)); + + // Extract the response and validate the A1 hash + final String response = table.get("response"); + Assertions.assertNotNull(response); + } }