001/** 002 * 003 * Copyright 2014-2019 Florian Schmaus 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.jivesoftware.smack.sasl.provided; 018 019import java.nio.charset.StandardCharsets; 020 021import javax.security.auth.callback.CallbackHandler; 022 023import org.jivesoftware.smack.SmackException.SmackSaslException; 024import org.jivesoftware.smack.sasl.SASLMechanism; 025import org.jivesoftware.smack.util.ByteUtils; 026import org.jivesoftware.smack.util.MD5; 027import org.jivesoftware.smack.util.StringUtils; 028 029public class SASLDigestMD5Mechanism extends SASLMechanism { 030 031 public static final String NAME = DIGESTMD5; 032 033 private static final String INITAL_NONCE = "00000001"; 034 035 /** 036 * The only 'qop' value supported by this implementation 037 */ 038 private static final String QOP_VALUE = "auth"; 039 040 private enum State { 041 INITIAL, 042 RESPONSE_SENT, 043 VALID_SERVER_RESPONSE, 044 } 045 046 private static boolean verifyServerResponse = true; 047 048 public static void setVerifyServerResponse(boolean verifyServerResponse) { 049 SASLDigestMD5Mechanism.verifyServerResponse = verifyServerResponse; 050 } 051 052 /** 053 * The state of the this instance of SASL DIGEST-MD5 authentication. 054 */ 055 private State state = State.INITIAL; 056 057 private String nonce; 058 private String cnonce; 059 private String digestUri; 060 private String hex_hashed_a1; 061 062 @Override 063 protected void authenticateInternal(CallbackHandler cbh) { 064 throw new UnsupportedOperationException("CallbackHandler not (yet) supported"); 065 } 066 067 @Override 068 protected byte[] getAuthenticationText() { 069 // DIGEST-MD5 has no initial response, return null 070 return null; 071 } 072 073 @Override 074 public String getName() { 075 return NAME; 076 } 077 078 @Override 079 public int getPriority() { 080 return 210; 081 } 082 083 @Override 084 public SASLDigestMD5Mechanism newInstance() { 085 return new SASLDigestMD5Mechanism(); 086 } 087 088 @Override 089 public boolean authzidSupported() { 090 return true; 091 } 092 093 094 @Override 095 public void checkIfSuccessfulOrThrow() throws SmackSaslException { 096 if (verifyServerResponse && state != State.VALID_SERVER_RESPONSE) { 097 throw new SmackSaslException(NAME + " no valid server response"); 098 } 099 } 100 101 @Override 102 protected byte[] evaluateChallenge(byte[] challenge) throws SmackSaslException { 103 if (challenge.length == 0) { 104 throw new SmackSaslException("Initial challenge has zero length"); 105 } 106 String challengeString = new String(challenge, StandardCharsets.UTF_8); 107 String[] challengeParts = challengeString.split(","); 108 byte[] response = null; 109 switch (state) { 110 case INITIAL: 111 for (String part : challengeParts) { 112 String[] keyValue = part.split("=", 2); 113 String key = keyValue[0]; 114 // RFC 2831 § 7.1 about the formatting of the digest-challenge: 115 // "The full form is "<n>#<m>element" indicating at least <n> and 116 // at most <m> elements, each separated by one or more commas 117 // (",") and OPTIONAL linear white space (LWS)." 118 // Which means the key value may be preceded by whitespace, 119 // which is what we remove: *Only the preceding whitespace*. 120 key = key.replaceFirst("^\\s+", ""); 121 String value = keyValue[1]; 122 if ("nonce".equals(key)) { 123 if (nonce != null) { 124 throw new SmackSaslException("Nonce value present multiple times"); 125 } 126 nonce = value.replace("\"", ""); 127 } 128 else if ("qop".equals(key)) { 129 value = value.replace("\"", ""); 130 if (!value.equals("auth")) { 131 throw new SmackSaslException("Unsupported qop operation: " + value); 132 } 133 } 134 } 135 if (nonce == null) { 136 // RFC 2831 2.1.1 about nonce "This directive is required and MUST appear exactly 137 // once; if not present, or if multiple instances are present, the client should 138 // abort the authentication exchange." 139 throw new SmackSaslException("nonce value not present in initial challenge"); 140 } 141 // RFC 2831 2.1.2.1 defines A1, A2, KD and response-value 142 byte[] a1FirstPart = MD5.bytes(authenticationId + ':' + serviceName + ':' 143 + password); 144 cnonce = StringUtils.randomString(32); 145 byte[] a1 = ByteUtils.concat(a1FirstPart, toBytes(':' + nonce + ':' + cnonce)); 146 digestUri = "xmpp/" + serviceName; 147 hex_hashed_a1 = StringUtils.encodeHex(MD5.bytes(a1)); 148 String responseValue = calcResponse(DigestType.ClientResponse); 149 // @formatter:off 150 // See RFC 2831 2.1.2 digest-response 151 String authzid; 152 if (authorizationId == null) { 153 authzid = ""; 154 } else { 155 authzid = ",authzid=\"" + authorizationId + '"'; 156 } 157 String saslString = "username=\"" + quoteBackslash(authenticationId) + '"' 158 + authzid 159 + ",realm=\"" + serviceName + '"' 160 + ",nonce=\"" + nonce + '"' 161 + ",cnonce=\"" + cnonce + '"' 162 + ",nc=" + INITAL_NONCE 163 + ",qop=auth" 164 + ",digest-uri=\"" + digestUri + '"' 165 + ",response=" + responseValue 166 + ",charset=utf-8"; 167 // @formatter:on 168 response = toBytes(saslString); 169 state = State.RESPONSE_SENT; 170 break; 171 case RESPONSE_SENT: 172 if (verifyServerResponse) { 173 String serverResponse = null; 174 for (String part : challengeParts) { 175 String[] keyValue = part.split("="); 176 assert keyValue.length == 2; 177 String key = keyValue[0]; 178 String value = keyValue[1]; 179 if ("rspauth".equals(key)) { 180 serverResponse = value; 181 break; 182 } 183 } 184 if (serverResponse == null) { 185 throw new SmackSaslException("No server response received while performing " + NAME 186 + " authentication"); 187 } 188 String expectedServerResponse = calcResponse(DigestType.ServerResponse); 189 if (!serverResponse.equals(expectedServerResponse)) { 190 throw new SmackSaslException("Invalid server response while performing " + NAME 191 + " authentication"); 192 } 193 } 194 state = State.VALID_SERVER_RESPONSE; 195 break; 196 default: 197 throw new IllegalStateException(); 198 } 199 return response; 200 } 201 202 private enum DigestType { 203 ClientResponse, 204 ServerResponse 205 } 206 207 private String calcResponse(DigestType digestType) { 208 StringBuilder a2 = new StringBuilder(); 209 if (digestType == DigestType.ClientResponse) { 210 a2.append("AUTHENTICATE"); 211 } 212 a2.append(':'); 213 a2.append(digestUri); 214 String hex_hashed_a2 = StringUtils.encodeHex(MD5.bytes(a2.toString())); 215 216 StringBuilder kd_argument = new StringBuilder(); 217 kd_argument.append(hex_hashed_a1); 218 kd_argument.append(':'); 219 kd_argument.append(nonce); 220 kd_argument.append(':'); 221 kd_argument.append(INITAL_NONCE); 222 kd_argument.append(':'); 223 kd_argument.append(cnonce); 224 kd_argument.append(':'); 225 kd_argument.append(QOP_VALUE); 226 kd_argument.append(':'); 227 kd_argument.append(hex_hashed_a2); 228 byte[] kd = MD5.bytes(kd_argument.toString()); 229 String responseValue = StringUtils.encodeHex(kd); 230 return responseValue; 231 } 232 233 /** 234 * Quote the backslash in the given String. Replaces all occurrences of "\" with "\\". 235 * <p> 236 * According to RFC 2831 § 7.2 a quoted-string consists either of qdtext or quoted-pair. And since quoted-pair is a 237 * backslash followed by a char, every backslash in qdtext must be quoted, since it otherwise would be treated as 238 * qdtext. 239 * </p> 240 * 241 * @param string the input string. 242 * @return the input string where the every backslash is quoted. 243 */ 244 public static String quoteBackslash(String string) { 245 return string.replace("\\", "\\\\"); 246 } 247}