From 32f6954b4e4b7197acf253281659c4956d7af000 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 13 Oct 2026 12:26:38 +0200 Subject: [PATCH] Tighten RFC 7804 SCRAM message validation. This change tightens SCRAM-SHA-256 validation for the initial authentication exchange according to RFC 5802, RFC 7677 and RFC 7804. It adds stricter validation of SCRAM message structure and attributes, canonical Base64 encoding, strict UTF-8 decoding, duplicate parameter detection, and RFC-compliant handling of password preparation. --- .../client5/http/impl/auth/ScramScheme.java | 300 ++++++++++---- .../http/impl/auth/TestScramScheme.java | 8 +- .../auth/TestScramSchemeRfcCompliance.java | 380 ++++++++++++++++++ 3 files changed, 609 insertions(+), 79 deletions(-) create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestScramSchemeRfcCompliance.java 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)); + } +}