diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/ScramScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/ScramScheme.java index f843782bcc..da10fd9d5a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/ScramScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/ScramScheme.java @@ -26,6 +26,9 @@ */ package org.apache.hc.client5.http.impl.auth; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; @@ -34,6 +37,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -87,7 +91,7 @@ public final class ScramScheme implements AuthScheme { private static final String GS2_HEADER = "n,,"; private static final String C_BIND_B64 = "biws"; // base64("n,,") - private static final Base64.Encoder B64 = Base64.getEncoder().withoutPadding(); + private static final Base64.Encoder B64 = Base64.getEncoder(); private static final Base64.Decoder B64D = Base64.getDecoder(); private enum State { @@ -112,7 +116,7 @@ private enum State { private String sid; private String username; // SASLprep (query) - private char[] password; // SASLprep (stored), zeroed after use + private char[] password; // printable US-ASCII fallback, zeroed after use private Principal principal; private String clientNonce; @@ -235,8 +239,14 @@ public void processChallenge( } throw new MalformedChallengeException("Null SCRAM challenge"); } - - final Map params = toParamMap(authChallenge.getParams()); + final Map params; + try { + params = toParamMap(authChallenge.getParams()); + } catch (final MalformedChallengeException ex) { + zeroAndClearExpectedV(); + this.state = State.FAILED; + throw ex; + } if (challenged) { // --- 401 path (WWW-Authenticate) --- @@ -261,6 +271,11 @@ public void processChallenge( this.state = State.FAILED; throw new MalformedChallengeException("SCRAM server-first missing sid"); } + if (!isToken(sid)) { + zeroAndClearExpectedV(); + this.state = State.FAILED; + throw new MalformedChallengeException("SCRAM server-first invalid sid"); + } // server-first (data present) final String decoded; @@ -272,48 +287,46 @@ public void processChallenge( throw ex; } this.serverFirstRaw = decoded; - final Map attrs; try { attrs = parseAttrs(decoded); + validateServerFirstAttrs(attrs); } catch (final MalformedChallengeException ex) { zeroAndClearExpectedV(); this.state = State.FAILED; throw ex; } - final String r = attrs.get("r"); final String s = attrs.get("s"); final String i = attrs.get("i"); - if (r == null || r.isEmpty() || s == null || s.isEmpty() || i == null || i.isEmpty()) { + if (!isPrintableNonce(r)) { this.state = State.FAILED; - throw new MalformedChallengeException("SCRAM server-first missing r/s/i"); + throw new MalformedChallengeException("Invalid SCRAM server nonce syntax"); } if (this.clientNonce == null || !r.startsWith(this.clientNonce) || r.length() <= this.clientNonce.length()) { this.state = State.FAILED; throw new AuthenticationException("SCRAM server nonce does not extend client nonce"); } - try { - this.salt = B64D.decode(s); + this.salt = decodeCanonicalBase64(s, "salt"); if (this.salt.length == 0) { - throw new IllegalArgumentException("empty salt"); + throw new MalformedChallengeException("Empty SCRAM salt"); } - } catch (final IllegalArgumentException e) { + } catch (final MalformedChallengeException ex) { + this.state = State.FAILED; + throw ex; + } + if (!isPositiveNumber(i)) { this.state = State.FAILED; - throw new MalformedChallengeException("Invalid base64 salt", e); + throw new MalformedChallengeException("Invalid SCRAM iteration count"); } try { this.iterations = Integer.parseInt(i); - if (this.iterations <= 0) { - throw new NumberFormatException("i<=0"); - } } catch (final NumberFormatException e) { this.state = State.FAILED; - throw new MalformedChallengeException("Invalid iteration count", e); + throw new MalformedChallengeException("SCRAM iteration count is too large", e); } - if (this.minIterationsRequired > 0 && this.iterations < this.minIterationsRequired) { this.state = State.FAILED; throw new AuthenticationException( @@ -349,6 +362,11 @@ public void processChallenge( this.state = State.FAILED; throw new MalformedChallengeException("SCRAM Authentication-Info missing sid"); } + if (!isToken(sid)) { + zeroAndClearExpectedV(); + this.state = State.FAILED; + throw new MalformedChallengeException("SCRAM Authentication-Info invalid sid"); + } if (this.sid == null || !this.sid.equals(sid)) { zeroAndClearExpectedV(); this.state = State.FAILED; @@ -374,6 +392,7 @@ public void processChallenge( final Map attrs; try { attrs = parseAttrs(decoded); + validateServerFinalAttrs(attrs); } catch (final MalformedChallengeException ex) { zeroAndClearExpectedV(); this.state = State.FAILED; @@ -381,35 +400,24 @@ public void processChallenge( } final String err = attrs.get("e"); if (err != null) { + zeroAndClearExpectedV(); this.state = State.FAILED; - if (err.isEmpty()) { - throw new MalformedChallengeException("SCRAM server error attribute 'e' is empty"); - } throw new AuthenticationException("SCRAM server error: " + err); } final String vB64 = attrs.get("v"); - if (vB64 == null) { - zeroAndClearExpectedV(); - this.state = State.FAILED; - throw new MalformedChallengeException("SCRAM Authentication-Info missing v"); - } - - // compare 'v' in constant time; treat bad base64 for v as a signature mismatch (tests expect "signature") final byte[] expected = this.expectedV; - this.expectedV = null; // clear reference early - + this.expectedV = null; byte[] vBytes = null; boolean match; try { - vBytes = B64D.decode(vB64); + vBytes = decodeCanonicalBase64(vB64, "server signature"); match = expected != null && MessageDigest.isEqual(expected, vBytes); - } catch (final IllegalArgumentException e) { - match = false; // invalid base64 for v -> treat as mismatch + } catch (final MalformedChallengeException ex) { + match = false; } finally { zero(vBytes); zero(expected); } - if (!match) { this.state = State.FAILED; throw new MalformedChallengeException("SCRAM server signature mismatch"); @@ -458,23 +466,37 @@ public boolean isResponseReady( } final UsernamePasswordCredentials up = (UsernamePasswordCredentials) creds; - // SASLprep: username as query, password as stored + // RFC 5802 prepares the username with SASLprep. RFC 7804 defines password + // Normalize() using RFC 7613 OpaqueString and explicitly permits clients that do not + // implement OpaqueString to reject non-US-ASCII passwords. final String preppedUser; final char[] passChars = up.getUserPassword() != null ? up.getUserPassword().clone() : null; try { preppedUser = SaslPrep.INSTANCE.prepAsQueryString(up.getUserName()); - final String preppedPassStr = SaslPrep.INSTANCE.prepAsStoredString( - passChars != null ? new String(passChars) : null); + if (preppedUser == null || preppedUser.isEmpty()) { + throw new AuthenticationException("SCRAM username preparation produced an empty value"); + } + if (passChars == null || passChars.length == 0) { + throw new AuthenticationException("SCRAM password is missing or empty"); + } + for (final char ch : passChars) { + if (ch < 0x20 || ch > 0x7e) { + throw new AuthenticationException( + "SCRAM password contains characters outside printable US-ASCII; " + + "RFC 7613 OpaqueString is not implemented"); + } + } this.username = preppedUser; - this.password = preppedPassStr != null ? preppedPassStr.toCharArray() : null; + this.password = passChars.clone(); + } catch (final AuthenticationException ex) { + throw ex; } catch (final Exception e) { - throw new AuthenticationException("SASLprep failed", e); + throw new AuthenticationException("SASLprep failed for SCRAM username", e); } finally { if (passChars != null) { Arrays.fill(passChars, '\0'); } } - this.principal = new SimplePrincipal(this.username); switch (this.state) { @@ -574,7 +596,7 @@ private String buildClientFinalAndExpectV() throws AuthenticationException { final StringBuilder sb = new StringBuilder(64); sb.append(StandardAuthScheme.SCRAM_SHA_256).append(' '); - sb.append("sid=").append(quoteParam(this.sid)).append(", "); + sb.append("sid=").append(this.sid).append(", "); sb.append("data=").append(quoteParam(data)); // quoted this.state = State.CLIENT_FINAL_SENT; @@ -611,57 +633,178 @@ private void zeroAndClearExpectedV() { this.expectedV = null; } } - - private static Map toParamMap(final List pairs) { + private static Map toParamMap(final List pairs) + throws MalformedChallengeException { final Map m = new HashMap<>(); if (pairs != null) { for (final NameValuePair p : pairs) { if (p != null && p.getName() != null) { - m.put(p.getName().toLowerCase(Locale.ROOT), p.getValue()); + final String name = p.getName().toLowerCase(Locale.ROOT); + if (m.putIfAbsent(name, p.getValue()) != null) { + throw new MalformedChallengeException("Duplicate SCRAM auth-param: " + name); + } } } } return m; } - // Parse "k=v(,k=v)*" with RFC 5802 escapes: "=2C"/"=2c" -> ",", "=3D"/"=3d" -> "=" + /** Generic SCRAM attribute parser. Message-specific order and semantics are validated separately. */ private static Map parseAttrs(final String s) throws MalformedChallengeException { final Map out = new LinkedHashMap<>(); - int i = 0; - while (i < s.length()) { - if (i + 2 > s.length() || s.charAt(i + 1) != '=') { - throw new MalformedChallengeException("Bad SCRAM attr at index " + i); - } - final String k = String.valueOf(s.charAt(i)); - i += 2; - final StringBuilder v = new StringBuilder(); - while (i < s.length()) { - final char c = s.charAt(i); - if (c == ',') { - i++; - break; + if (s == null || s.isEmpty()) { + throw new MalformedChallengeException("Empty SCRAM message"); + } + final String[] parts = s.split(",", -1); + for (final String part : parts) { + if (part.length() < 2 || part.charAt(1) != '=' || !isAlpha(part.charAt(0))) { + throw new MalformedChallengeException("Bad SCRAM attribute: " + part); + } + final String key = part.substring(0, 1); + final String value = part.substring(2); + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) == '\0') { + throw new MalformedChallengeException("NUL is not permitted in SCRAM attribute values"); } - if (c == '=' && i + 2 < s.length()) { - final String esc = s.substring(i, i + 3); - if ("=2C".equalsIgnoreCase(esc)) { - v.append(','); - i += 3; - continue; - } - if ("=3D".equalsIgnoreCase(esc)) { - v.append('='); - i += 3; - continue; - } + } + if (out.containsKey(key)) { + if (isReservedScramAttribute(key)) { + throw new MalformedChallengeException("Duplicate SCRAM attribute: " + key); + } + if (value.isEmpty()) { + throw new MalformedChallengeException("Empty SCRAM extension attribute: " + key); } - v.append(c); - i++; + continue; } - out.put(k, v.toString()); + out.put(key, value); } return out; } + private static void validateServerFirstAttrs(final Map attrs) + throws MalformedChallengeException { + if (attrs.containsKey("m")) { + throw new MalformedChallengeException("Unsupported mandatory SCRAM extension 'm'"); + } + if (!attrs.containsKey("r") || !attrs.containsKey("s") || !attrs.containsKey("i")) { + throw new MalformedChallengeException("SCRAM server-first missing r/s/i"); + } + final Iterator it = attrs.keySet().iterator(); + if (!it.hasNext() || !"r".equals(it.next()) + || !it.hasNext() || !"s".equals(it.next()) + || !it.hasNext() || !"i".equals(it.next())) { + throw new MalformedChallengeException("Invalid SCRAM server-first attribute order; expected r,s,i"); + } + while (it.hasNext()) { + final String key = it.next(); + if (isReservedScramAttribute(key)) { + throw new MalformedChallengeException("SCRAM attribute out of position: " + key); + } + if (attrs.get(key).isEmpty()) { + throw new MalformedChallengeException("Empty SCRAM extension attribute: " + key); + } + } + if (attrs.get("r").isEmpty() || attrs.get("s").isEmpty() || attrs.get("i").isEmpty()) { + throw new MalformedChallengeException("SCRAM server-first missing r/s/i value"); + } + } + + private static void validateServerFinalAttrs(final Map attrs) + throws MalformedChallengeException { + if (attrs.containsKey("m")) { + throw new MalformedChallengeException("Unsupported mandatory SCRAM extension 'm'"); + } + final Iterator it = attrs.keySet().iterator(); + if (!it.hasNext()) { + throw new MalformedChallengeException("Empty SCRAM server-final message"); + } + final String first = it.next(); + if (!"e".equals(first) && !"v".equals(first)) { + throw new MalformedChallengeException("SCRAM server-final must start with e or v"); + } + if (attrs.get(first).isEmpty()) { + throw new MalformedChallengeException("Empty SCRAM server-final " + first + " value"); + } + while (it.hasNext()) { + final String key = it.next(); + if (isReservedScramAttribute(key)) { + throw new MalformedChallengeException("SCRAM attribute out of position: " + key); + } + if (attrs.get(key).isEmpty()) { + throw new MalformedChallengeException("Empty SCRAM extension attribute: " + key); + } + } + } + + private static boolean isReservedScramAttribute(final String key) { + return "a".equals(key) || "c".equals(key) || "e".equals(key) || "i".equals(key) + || "m".equals(key) || "n".equals(key) || "p".equals(key) || "r".equals(key) + || "s".equals(key) || "v".equals(key); + } + + private static boolean isAlpha(final char ch) { + return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'; + } + + private static boolean isPositiveNumber(final String value) { + if (value == null || value.isEmpty() || value.charAt(0) < '1' || value.charAt(0) > '9') { + return false; + } + for (int i = 1; i < value.length(); i++) { + final char ch = value.charAt(i); + if (ch < '0' || ch > '9') { + return false; + } + } + return true; + } + + private static boolean isPrintableNonce(final String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + final char ch = value.charAt(i); + if (ch < 0x21 || ch > 0x7e || ch == ',') { + return false; + } + } + return true; + } + + private static boolean isToken(final String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + final char ch = value.charAt(i); + final boolean alphaNum = ch >= '0' && ch <= '9' || ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'; + final boolean tchar = alphaNum || ch == '!' || ch == '#' || ch == '$' || ch == '%' || ch == '&' + || ch == '\'' || ch == '*' || ch == '+' || ch == '-' || ch == '.' || ch == '^' + || ch == '_' || ch == '`' || ch == '|' || ch == '~'; + if (!tchar) { + return false; + } + } + return true; + } + + private static byte[] decodeCanonicalBase64(final String value, final String what) + throws MalformedChallengeException { + if (value == null) { + throw new MalformedChallengeException("Missing SCRAM " + what); + } + try { + final byte[] decoded = B64D.decode(value); + if (!B64.encodeToString(decoded).equals(value)) { + throw new MalformedChallengeException("Non-canonical base64 SCRAM " + what); + } + return decoded; + } catch (final IllegalArgumentException ex) { + throw new MalformedChallengeException("Invalid base64 SCRAM " + what, ex); + } + } + private String genNonce() { final byte[] buf = new byte[16]; this.secureRandom.nextBytes(buf); @@ -742,10 +885,15 @@ private static String stringToB64(final String s) { } private static String b64ToString(final String b64) throws MalformedChallengeException { + final byte[] decoded = decodeCanonicalBase64(b64, "data value"); try { - return new String(B64D.decode(b64), StandardCharsets.UTF_8); - } catch (final IllegalArgumentException e) { - throw new MalformedChallengeException("Bad base64 'data' value", e); + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(decoded)) + .toString(); + } catch (final CharacterCodingException ex) { + throw new MalformedChallengeException("Invalid UTF-8 in SCRAM data value", ex); } } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramScheme.java index 8ef68cd29c..56afa74ec6 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramScheme.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramScheme.java @@ -65,7 +65,7 @@ final class TestScramScheme { private static final String SID = "sid-1"; static String b64(final byte[] b) { - return Base64.getEncoder().withoutPadding().encodeToString(b); + return Base64.getEncoder().encodeToString(b); } static byte[] b64d(final String s) { @@ -633,7 +633,7 @@ void testInvalidStateTransition() throws Exception { } @Test - void testEmptyPassword() throws Exception { + void testEmptyPasswordRejected() throws Exception { final ScramScheme scheme = new ScramScheme(); final BasicCredentialsProvider creds = new BasicCredentialsProvider(); creds.setCredentials(new AuthScope(HOST, REALM, scheme.getName()), @@ -644,7 +644,9 @@ void testEmptyPassword() throws Exception { new AuthChallenge(ChallengeType.TARGET, scheme.getName(), new BasicNameValuePair("realm", REALM)), ctx); - assertTrue(scheme.isResponseReady(HOST, creds, ctx)); + final AuthenticationException ex = assertThrows(AuthenticationException.class, + () -> scheme.isResponseReady(HOST, creds, ctx)); + assertTrue(ex.getMessage().toLowerCase(Locale.ROOT).contains("empty")); } @Test diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramSchemeRfcCompliance.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramSchemeRfcCompliance.java new file mode 100644 index 0000000000..25d5f702d0 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramSchemeRfcCompliance.java @@ -0,0 +1,380 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.hc.client5.http.auth.AuthChallenge; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.ChallengeType; +import org.apache.hc.client5.http.auth.MalformedChallengeException; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.junit.jupiter.api.Test; + +final class TestScramSchemeRfcCompliance { + + private static final HttpHost HOST = new HttpHost("https", "example.test"); + private static final String REALM = "s1"; + private static final String SID = "sid-1"; + + private static String b64(final byte[] value) { + return Base64.getEncoder().encodeToString(value); + } + + private static String b64(final String value) { + return b64(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String deb64(final String value) { + return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + } + + private static Map attrs(final String value) { + final Map result = new LinkedHashMap<>(); + for (final String part : value.split(",")) { + final int i = part.indexOf('='); + result.put(part.substring(0, i), part.substring(i + 1)); + } + return result; + } + + private static String param(final String header, final String name) { + final String marker = name + "="; + int p = header.indexOf(marker); + if (p < 0) { + return null; + } + p += marker.length(); + if (p < header.length() && header.charAt(p) == '"') { + final int end = header.indexOf('"', p + 1); + return header.substring(p + 1, end); + } + int end = header.indexOf(',', p); + if (end < 0) { + end = header.length(); + } + return header.substring(p, end).trim(); + } + + private static final class Started { + final ScramScheme scheme; + final HttpClientContext context; + final String clientFirstBare; + final String clientNonce; + + Started(final ScramScheme scheme, final HttpClientContext context, + final String clientFirstBare, final String clientNonce) { + this.scheme = scheme; + this.context = context; + this.clientFirstBare = clientFirstBare; + this.clientNonce = clientNonce; + } + } + + private static Started start() throws Exception { + return start("u", "p".toCharArray()); + } + + private static Started start(final String user, final char[] password) throws Exception { + final ScramScheme scheme = new ScramScheme(); + final BasicCredentialsProvider creds = new BasicCredentialsProvider(); + creds.setCredentials(new AuthScope(HOST, REALM, scheme.getName()), + new UsernamePasswordCredentials(user, password)); + final HttpClientContext context = HttpClientContext.create(); + scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, scheme.getName(), + new BasicNameValuePair("realm", REALM)), context); + assertTrue(scheme.isResponseReady(HOST, creds, context)); + final String authorization = scheme.generateAuthResponse(HOST, null, context); + final String first = deb64(param(authorization, "data")); + final String bare = first.substring("n,,".length()); + return new Started(scheme, context, bare, attrs(bare).get("r")); + } + + private static void serverFirst(final Started started, final String message) throws Exception { + started.scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", b64(message))), + started.context); + } + + private static String normalServerFirst(final Started started) { + return "r=" + started.clientNonce + "Z,s=" + b64("salt") + ",i=4096"; + } + + @Test + void canonicalBase64IsGenerated() throws Exception { + final Started started = start(); + serverFirst(started, normalServerFirst(started)); + final String authorization = started.scheme.generateAuthResponse(HOST, null, started.context); + final String data = param(authorization, "data"); + assertEquals(data, b64(Base64.getDecoder().decode(data))); + final String proof = attrs(deb64(data)).get("p"); + assertEquals(proof, b64(Base64.getDecoder().decode(proof))); + assertTrue(proof.endsWith("=")); + assertTrue(authorization.contains("sid=" + SID + ",")); + assertFalse(authorization.contains("sid=\"")); + } + + @Test + void unpaddedOuterDataIsRejected() throws Exception { + final Started started = start(); + final String message = normalServerFirst(started); + String data = b64(message); + while (data.endsWith("=")) { + data = data.substring(0, data.length() - 1); + } + final String unpadded = data; + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", unpadded)), started.context)); + } + + @Test + void unpaddedSaltIsRejected() throws Exception { + final Started started = start(); + final String canonical = b64("salt"); + final String unpadded = canonical.substring(0, canonical.length() - 1); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "r=" + started.clientNonce + "Z,s=" + unpadded + ",i=4096")); + } + + @Test + void serverNonceMustActuallyExtendClientNonce() throws Exception { + final Started started = start(); + assertThrows(AuthenticationException.class, () -> + serverFirst(started, "r=" + started.clientNonce + ",s=" + b64("salt") + ",i=4096")); + } + + @Test + void serverNonceMustUsePrintableAsciiExceptComma() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "r=" + started.clientNonce + " Z,s=" + b64("salt") + ",i=4096")); + } + + @Test + void leadingZeroIterationCountIsRejected() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "r=" + started.clientNonce + "Z,s=" + b64("salt") + ",i=04096")); + } + + @Test + void mandatoryExtensionIsRejected() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "m=required,r=" + started.clientNonce + "Z,s=" + b64("salt") + ",i=4096")); + } + + @Test + void serverFirstMissingRequiredAttributesIsRejectedAsMissing() throws Exception { + final Started started = start(); + final MalformedChallengeException ex = assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "r=" + started.clientNonce + "Z")); + assertTrue(ex.getMessage().contains("missing")); + } + + @Test + void serverFirstAttributeOrderIsFixed() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "s=" + b64("salt") + ",r=" + started.clientNonce + "Z,i=4096")); + } + + @Test + void duplicateScramAttributeIsRejected() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, "r=" + started.clientNonce + "Z,s=" + b64("salt") + + ",i=4096,i=4097")); + } + + @Test + void emptyOptionalExtensionIsRejected() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, normalServerFirst(started) + ",x=")); + } + + @Test + void unknownNonEmptyOptionalExtensionIsIgnored() throws Exception { + final Started started = start(); + serverFirst(started, normalServerFirst(started) + ",x=hello"); + final String authorization = started.scheme.generateAuthResponse(HOST, null, started.context); + assertTrue(authorization.contains("sid=" + SID)); + } + + @Test + void reservedAttributeCannotAppearAsExtension() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + serverFirst(started, normalServerFirst(started) + ",v=AAAA")); + } + + @Test + void invalidUtf8InDataIsRejected() throws Exception { + final Started started = start(); + final byte[] invalid = new byte[] {(byte) 0xc3, 0x28}; + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", b64(invalid))), started.context)); + } + + @Test + void duplicateHttpAuthParamIsRejected() throws Exception { + final Started started = start(); + final String data = b64(normalServerFirst(started)); + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", data), + new BasicNameValuePair("DATA", data)), started.context)); + } + + @Test + void invalidSidTokenIsRejected() throws Exception { + final Started started = start(); + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", "bad sid"), + new BasicNameValuePair("data", b64(normalServerFirst(started)))), started.context)); + } + + private static AuthenticationException assertPasswordRejected(final char[] password) throws Exception { + final ScramScheme scheme = new ScramScheme(); + final BasicCredentialsProvider creds = new BasicCredentialsProvider(); + creds.setCredentials(new AuthScope(HOST, REALM, scheme.getName()), + new UsernamePasswordCredentials("u", password)); + final HttpClientContext context = HttpClientContext.create(); + scheme.processChallenge(HOST, true, + new AuthChallenge(ChallengeType.TARGET, scheme.getName(), + new BasicNameValuePair("realm", REALM)), context); + return assertThrows(AuthenticationException.class, + () -> scheme.isResponseReady(HOST, creds, context)); + } + + @Test + void nonAsciiPasswordIsRejectedWithoutOpaqueStringImplementation() throws Exception { + final AuthenticationException ex = assertPasswordRejected("páss".toCharArray()); + assertTrue(ex.getMessage().contains("OpaqueString")); + } + + @Test + void asciiControlPasswordIsRejectedWithoutOpaqueStringImplementation() throws Exception { + final AuthenticationException ex = assertPasswordRejected(new char[] {'p', '\u001f', 'x'}); + assertTrue(ex.getMessage().contains("printable US-ASCII")); + } + + @Test + void asciiDeletePasswordIsRejectedWithoutOpaqueStringImplementation() throws Exception { + final AuthenticationException ex = assertPasswordRejected(new char[] {'p', '\u007f', 'x'}); + assertTrue(ex.getMessage().contains("printable US-ASCII")); + } + + @Test + void serverFinalMustStartWithErrorOrVerifier() throws Exception { + final Started started = start(); + serverFirst(started, normalServerFirst(started)); + started.scheme.generateAuthResponse(HOST, null, started.context); + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, false, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", b64("x=hello,v=AAAA"))), started.context)); + } + + @Test + void serverFinalCannotContainBothVerifierAndError() throws Exception { + final Started started = start(); + serverFirst(started, normalServerFirst(started)); + started.scheme.generateAuthResponse(HOST, null, started.context); + assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, false, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", b64("v=AAAA,e=other-error"))), started.context)); + } + + @Test + void unpaddedVerifierIsRejectedAsSignatureMismatch() throws Exception { + final Started started = start(); + final String serverFirst = normalServerFirst(started); + serverFirst(started, serverFirst); + final String clientFinal = deb64(param(started.scheme.generateAuthResponse(HOST, null, started.context), "data")); + final Map cf = attrs(clientFinal); + final String noProof = "c=" + cf.get("c") + ",r=" + cf.get("r"); + final String authMessage = started.clientFirstBare + "," + serverFirst + "," + noProof; + final byte[] salted = pbkdf2("p".toCharArray(), Base64.getDecoder().decode(b64("salt")), 4096, 32); + final byte[] serverKey = hmac(salted, "Server Key"); + final String canonical = b64(hmac(serverKey, authMessage)); + final String unpadded = canonical.substring(0, canonical.length() - 1); + final MalformedChallengeException ex = assertThrows(MalformedChallengeException.class, () -> + started.scheme.processChallenge(HOST, false, + new AuthChallenge(ChallengeType.TARGET, started.scheme.getName(), + new BasicNameValuePair("sid", SID), + new BasicNameValuePair("data", b64("v=" + unpadded))), started.context)); + assertTrue(ex.getMessage().contains("signature")); + } + + private static byte[] pbkdf2(final char[] password, final byte[] salt, final int iter, final int len) + throws GeneralSecurityException { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(new PBEKeySpec(password, salt, iter, len * 8)).getEncoded(); + } + + private static byte[] hmac(final byte[] key, final String message) throws GeneralSecurityException { + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + } +}