1/* 2 * Copyright (C) 2016 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.phone.common.mail.store.imap; 18 19import android.annotation.Nullable; 20import android.util.ArrayMap; 21import android.util.Base64; 22 23import com.android.internal.annotations.VisibleForTesting; 24import com.android.phone.common.mail.MailTransport; 25import com.android.phone.common.mail.MessagingException; 26import com.android.phone.common.mail.store.ImapStore; 27import com.android.phone.vvm.omtp.VvmLog; 28 29import java.nio.charset.StandardCharsets; 30import java.security.MessageDigest; 31import java.security.NoSuchAlgorithmException; 32import java.security.SecureRandom; 33import java.util.Map; 34 35public class DigestMd5Utils { 36 37 private static final String TAG = "DigestMd5Utils"; 38 39 private static final String DIGEST_CHARSET = "CHARSET"; 40 private static final String DIGEST_USERNAME = "username"; 41 private static final String DIGEST_REALM = "realm"; 42 private static final String DIGEST_NONCE = "nonce"; 43 private static final String DIGEST_NC = "nc"; 44 private static final String DIGEST_CNONCE = "cnonce"; 45 private static final String DIGEST_URI = "digest-uri"; 46 private static final String DIGEST_RESPONSE = "response"; 47 private static final String DIGEST_QOP = "qop"; 48 49 private static final String RESPONSE_AUTH_HEADER = "rspauth="; 50 private static final String HEX_CHARS = "0123456789abcdef"; 51 52 /** 53 * Represents the set of data we need to generate the DIGEST-MD5 response. 54 */ 55 public static class Data { 56 57 private static final String CHARSET = "utf-8"; 58 59 public String username; 60 public String password; 61 public String realm; 62 public String nonce; 63 public String nc; 64 public String cnonce; 65 public String digestUri; 66 public String qop; 67 68 @VisibleForTesting 69 Data() { 70 // Do nothing 71 } 72 73 public Data(ImapStore imapStore, MailTransport transport, Map<String, String> challenge) { 74 username = imapStore.getUsername(); 75 password = imapStore.getPassword(); 76 realm = challenge.getOrDefault(DIGEST_REALM, ""); 77 nonce = challenge.get(DIGEST_NONCE); 78 cnonce = createCnonce(); 79 nc = "00000001"; // Subsequent Authentication not supported, nounce count always 1. 80 qop = "auth"; // Other config not supported 81 digestUri = "imap/" + transport.getHost(); 82 } 83 84 private static String createCnonce() { 85 SecureRandom generator = new SecureRandom(); 86 87 // At least 64 bits of entropy is required 88 byte[] rawBytes = new byte[8]; 89 generator.nextBytes(rawBytes); 90 91 return Base64.encodeToString(rawBytes, Base64.NO_WRAP); 92 } 93 94 /** 95 * Verify the response-auth returned by the server is correct. 96 */ 97 public void verifyResponseAuth(String response) 98 throws MessagingException { 99 if (!response.startsWith(RESPONSE_AUTH_HEADER)) { 100 throw new MessagingException("response-auth expected"); 101 } 102 if (!response.substring(RESPONSE_AUTH_HEADER.length()) 103 .equals(DigestMd5Utils.getResponse(this, true))) { 104 throw new MessagingException("invalid response-auth return from the server."); 105 } 106 } 107 108 public String createResponse() { 109 String response = getResponse(this, false); 110 ResponseBuilder builder = new ResponseBuilder(); 111 builder 112 .append(DIGEST_CHARSET, CHARSET) 113 .appendQuoted(DIGEST_USERNAME, username) 114 .appendQuoted(DIGEST_REALM, realm) 115 .appendQuoted(DIGEST_NONCE, nonce) 116 .append(DIGEST_NC, nc) 117 .appendQuoted(DIGEST_CNONCE, cnonce) 118 .appendQuoted(DIGEST_URI, digestUri) 119 .append(DIGEST_RESPONSE, response) 120 .append(DIGEST_QOP, qop); 121 return builder.toString(); 122 } 123 124 private static class ResponseBuilder { 125 126 private StringBuilder mBuilder = new StringBuilder(); 127 128 public ResponseBuilder appendQuoted(String key, String value) { 129 if (mBuilder.length() != 0) { 130 mBuilder.append(","); 131 } 132 mBuilder.append(key).append("=\"").append(value).append("\""); 133 return this; 134 } 135 136 public ResponseBuilder append(String key, String value) { 137 if (mBuilder.length() != 0) { 138 mBuilder.append(","); 139 } 140 mBuilder.append(key).append("=").append(value); 141 return this; 142 } 143 144 @Override 145 public String toString() { 146 return mBuilder.toString(); 147 } 148 } 149 } 150 151 /* 152 response-value = 153 toHex( getKeyDigest ( toHex(getMd5(a1)), 154 { nonce-value, ":" nc-value, ":", 155 cnonce-value, ":", qop-value, ":", toHex(getMd5(a2)) })) 156 * @param isResponseAuth is the response the one the server is returning us. response-auth has 157 * different a2 format. 158 */ 159 @VisibleForTesting 160 static String getResponse(Data data, boolean isResponseAuth) { 161 StringBuilder a1 = new StringBuilder(); 162 a1.append(new String( 163 getMd5(data.username + ":" + data.realm + ":" + data.password), 164 StandardCharsets.ISO_8859_1)); 165 a1.append(":").append(data.nonce).append(":").append(data.cnonce); 166 167 StringBuilder a2 = new StringBuilder(); 168 if (!isResponseAuth) { 169 a2.append("AUTHENTICATE"); 170 } 171 a2.append(":").append(data.digestUri); 172 173 return toHex(getKeyDigest( 174 toHex(getMd5(a1.toString())), 175 data.nonce + ":" + data.nc + ":" + data.cnonce + ":" + data.qop + ":" + toHex( 176 getMd5(a2.toString())) 177 )); 178 } 179 180 /** 181 * Let getMd5(s) be the 16 octet MD5 hash [RFC 1321] of the octet string s. 182 */ 183 private static byte[] getMd5(String s) { 184 try { 185 MessageDigest digester = MessageDigest.getInstance("MD5"); 186 digester.update(s.getBytes(StandardCharsets.ISO_8859_1)); 187 return digester.digest(); 188 } catch (NoSuchAlgorithmException e) { 189 throw new AssertionError(e); 190 } 191 } 192 193 /** 194 * Let getKeyDigest(k, s) be getMd5({k, ":", s}), i.e., the 16 octet hash of the string k, a colon and the 195 * string s. 196 */ 197 private static byte[] getKeyDigest(String k, String s) { 198 StringBuilder builder = new StringBuilder(k).append(":").append(s); 199 return getMd5(builder.toString()); 200 } 201 202 /** 203 * Let toHex(n) be the representation of the 16 octet MD5 hash n as a string of 32 hex digits 204 * (with alphabetic characters always in lower case, since MD5 is case sensitive). 205 */ 206 private static String toHex(byte[] n) { 207 StringBuilder result = new StringBuilder(); 208 for (byte b : n) { 209 int unsignedByte = b & 0xFF; 210 result.append(HEX_CHARS.charAt(unsignedByte / 16)) 211 .append(HEX_CHARS.charAt(unsignedByte % 16)); 212 } 213 return result.toString(); 214 } 215 216 public static Map<String, String> parseDigestMessage(String message) throws MessagingException { 217 Map<String, String> result = new DigestMessageParser(message).parse(); 218 if (!result.containsKey(DIGEST_NONCE)) { 219 throw new MessagingException("nonce missing from server DIGEST-MD5 challenge"); 220 } 221 return result; 222 } 223 224 /** 225 * Parse the key-value pair returned by the server. 226 */ 227 private static class DigestMessageParser { 228 229 private final String mMessage; 230 private int mPosition = 0; 231 private Map<String, String> mResult = new ArrayMap<>(); 232 233 public DigestMessageParser(String message) { 234 mMessage = message; 235 } 236 237 @Nullable 238 public Map<String, String> parse() { 239 try { 240 while (mPosition < mMessage.length()) { 241 parsePair(); 242 if (mPosition != mMessage.length()) { 243 expect(','); 244 } 245 } 246 } catch (IndexOutOfBoundsException e) { 247 VvmLog.e(TAG, e.toString()); 248 return null; 249 } 250 return mResult; 251 } 252 253 private void parsePair() { 254 String key = parseKey(); 255 expect('='); 256 String value = parseValue(); 257 mResult.put(key, value); 258 } 259 260 private void expect(char c) { 261 if (pop() != c) { 262 throw new IllegalStateException( 263 "unexpected character " + mMessage.charAt(mPosition)); 264 } 265 } 266 267 private char pop() { 268 char result = peek(); 269 mPosition++; 270 return result; 271 } 272 273 private char peek() { 274 return mMessage.charAt(mPosition); 275 } 276 277 private void goToNext(char c) { 278 while (peek() != c) { 279 mPosition++; 280 } 281 } 282 283 private String parseKey() { 284 int start = mPosition; 285 goToNext('='); 286 return mMessage.substring(start, mPosition); 287 } 288 289 private String parseValue() { 290 if (peek() == '"') { 291 return parseQuotedValue(); 292 } else { 293 return parseUnquotedValue(); 294 } 295 } 296 297 private String parseQuotedValue() { 298 expect('"'); 299 StringBuilder result = new StringBuilder(); 300 while (true) { 301 char c = pop(); 302 if (c == '\\') { 303 result.append(pop()); 304 } else if (c == '"') { 305 break; 306 } else { 307 result.append(c); 308 } 309 } 310 return result.toString(); 311 } 312 313 private String parseUnquotedValue() { 314 StringBuilder result = new StringBuilder(); 315 while (true) { 316 char c = pop(); 317 if (c == '\\') { 318 result.append(pop()); 319 } else if (c == ',') { 320 mPosition--; 321 break; 322 } else { 323 result.append(c); 324 } 325 326 if (mPosition == mMessage.length()) { 327 break; 328 } 329 } 330 return result.toString(); 331 } 332 } 333} 334