From da6d60f9f097fd2e16bc9dc0fdddf96fb4d4471f 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 32cd437ddf..ad4502afc2 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 @@ private enum QualityOfProtection { 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 void processChallenge( 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 @@ private String createDigestResponse(final HttpRequest request) throws Authentica } 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 @@ private String createDigestResponse(final HttpRequest request) throws Authentica // ":" 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 @@ private String createDigestResponse(final HttpRequest request) throws Authentica 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 @@ private String createDigestResponse(final HttpRequest request) throws Authentica 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 @@ private void readObject(final ObjectInputStream in) throws IOException, ClassNot 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 3f05886cc0..382212c834 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 void testDigestAuthenticationWithDefaultCreds() throws Exception { 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 void testDigestAuthentication() throws Exception { 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 void testDigestAuthenticationWithSHA() throws Exception { 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 void testDigestAuthenticationWithQueryStringInDigestURI() throws Exceptio 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 void testSerialization() throws Exception { 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); + } }