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 17 package com.android.voicemail.impl.mail.store.imap; 18 19 import android.annotation.TargetApi; 20 import android.os.Build.VERSION_CODES; 21 import android.support.annotation.Nullable; 22 import android.support.annotation.VisibleForTesting; 23 import android.util.ArrayMap; 24 import android.util.Base64; 25 import com.android.voicemail.impl.VvmLog; 26 import com.android.voicemail.impl.mail.MailTransport; 27 import com.android.voicemail.impl.mail.MessagingException; 28 import com.android.voicemail.impl.mail.store.ImapStore; 29 import java.nio.charset.StandardCharsets; 30 import java.security.MessageDigest; 31 import java.security.NoSuchAlgorithmException; 32 import java.security.SecureRandom; 33 import java.util.Map; 34 35 /** Utilities for DIGEST-MD5. */ 36 @TargetApi(VERSION_CODES.O) 37 public class DigestMd5Utils { 38 39 private static final String TAG = "DigestMd5Utils"; 40 41 private static final String DIGEST_CHARSET = "CHARSET"; 42 private static final String DIGEST_USERNAME = "username"; 43 private static final String DIGEST_REALM = "realm"; 44 private static final String DIGEST_NONCE = "nonce"; 45 private static final String DIGEST_NC = "nc"; 46 private static final String DIGEST_CNONCE = "cnonce"; 47 private static final String DIGEST_URI = "digest-uri"; 48 private static final String DIGEST_RESPONSE = "response"; 49 private static final String DIGEST_QOP = "qop"; 50 51 private static final String RESPONSE_AUTH_HEADER = "rspauth="; 52 private static final String HEX_CHARS = "0123456789abcdef"; 53 54 /** Represents the set of data we need to generate the DIGEST-MD5 response. */ 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 Data()69 Data() { 70 // Do nothing 71 } 72 Data(ImapStore imapStore, MailTransport transport, Map<String, String> challenge)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 createCnonce()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 /** Verify the response-auth returned by the server is correct. */ verifyResponseAuth(String response)95 public void verifyResponseAuth(String response) throws MessagingException { 96 if (!response.startsWith(RESPONSE_AUTH_HEADER)) { 97 throw new MessagingException("response-auth expected"); 98 } 99 if (!response 100 .substring(RESPONSE_AUTH_HEADER.length()) 101 .equals(DigestMd5Utils.getResponse(this, true))) { 102 throw new MessagingException("invalid response-auth return from the server."); 103 } 104 } 105 createResponse()106 public String createResponse() { 107 String response = getResponse(this, false); 108 ResponseBuilder builder = new ResponseBuilder(); 109 builder 110 .append(DIGEST_CHARSET, CHARSET) 111 .appendQuoted(DIGEST_USERNAME, username) 112 .appendQuoted(DIGEST_REALM, realm) 113 .appendQuoted(DIGEST_NONCE, nonce) 114 .append(DIGEST_NC, nc) 115 .appendQuoted(DIGEST_CNONCE, cnonce) 116 .appendQuoted(DIGEST_URI, digestUri) 117 .append(DIGEST_RESPONSE, response) 118 .append(DIGEST_QOP, qop); 119 return builder.toString(); 120 } 121 122 private static class ResponseBuilder { 123 124 private StringBuilder builder = new StringBuilder(); 125 appendQuoted(String key, String value)126 public ResponseBuilder appendQuoted(String key, String value) { 127 if (builder.length() != 0) { 128 builder.append(","); 129 } 130 builder.append(key).append("=\"").append(value).append("\""); 131 return this; 132 } 133 append(String key, String value)134 public ResponseBuilder append(String key, String value) { 135 if (builder.length() != 0) { 136 builder.append(","); 137 } 138 builder.append(key).append("=").append(value); 139 return this; 140 } 141 142 @Override toString()143 public String toString() { 144 return builder.toString(); 145 } 146 } 147 } 148 149 /* 150 response-value = 151 toHex( getKeyDigest ( toHex(getMd5(a1)), 152 { nonce-value, ":" nc-value, ":", 153 cnonce-value, ":", qop-value, ":", toHex(getMd5(a2)) })) 154 * @param isResponseAuth is the response the one the server is returning us. response-auth has 155 * different a2 format. 156 */ 157 @VisibleForTesting getResponse(Data data, boolean isResponseAuth)158 static String getResponse(Data data, boolean isResponseAuth) { 159 StringBuilder a1 = new StringBuilder(); 160 a1.append( 161 new String( 162 getMd5(data.username + ":" + data.realm + ":" + data.password), 163 StandardCharsets.ISO_8859_1)); 164 a1.append(":").append(data.nonce).append(":").append(data.cnonce); 165 166 StringBuilder a2 = new StringBuilder(); 167 if (!isResponseAuth) { 168 a2.append("AUTHENTICATE"); 169 } 170 a2.append(":").append(data.digestUri); 171 172 return toHex( 173 getKeyDigest( 174 toHex(getMd5(a1.toString())), 175 data.nonce 176 + ":" 177 + data.nc 178 + ":" 179 + data.cnonce 180 + ":" 181 + data.qop 182 + ":" 183 + toHex(getMd5(a2.toString())))); 184 } 185 186 /** Let getMd5(s) be the 16 octet MD5 hash [RFC 1321] of the octet string s. */ getMd5(String s)187 private static byte[] getMd5(String s) { 188 try { 189 MessageDigest digester = MessageDigest.getInstance("MD5"); 190 digester.update(s.getBytes(StandardCharsets.ISO_8859_1)); 191 return digester.digest(); 192 } catch (NoSuchAlgorithmException e) { 193 throw new AssertionError(e); 194 } 195 } 196 197 /** 198 * Let getKeyDigest(k, s) be getMd5({k, ":", s}), i.e., the 16 octet hash of the string k, a colon 199 * and the string s. 200 */ getKeyDigest(String k, String s)201 private static byte[] getKeyDigest(String k, String s) { 202 StringBuilder builder = new StringBuilder(k).append(":").append(s); 203 return getMd5(builder.toString()); 204 } 205 206 /** 207 * Let toHex(n) be the representation of the 16 octet MD5 hash n as a string of 32 hex digits 208 * (with alphabetic characters always in lower case, since MD5 is case sensitive). 209 */ toHex(byte[] n)210 private static String toHex(byte[] n) { 211 StringBuilder result = new StringBuilder(); 212 for (byte b : n) { 213 int unsignedByte = b & 0xFF; 214 result 215 .append(HEX_CHARS.charAt(unsignedByte / 16)) 216 .append(HEX_CHARS.charAt(unsignedByte % 16)); 217 } 218 return result.toString(); 219 } 220 parseDigestMessage(String message)221 public static Map<String, String> parseDigestMessage(String message) throws MessagingException { 222 Map<String, String> result = new DigestMessageParser(message).parse(); 223 if (!result.containsKey(DIGEST_NONCE)) { 224 throw new MessagingException("nonce missing from server DIGEST-MD5 challenge"); 225 } 226 return result; 227 } 228 229 /** Parse the key-value pair returned by the server. */ 230 private static class DigestMessageParser { 231 232 private final String message; 233 private int position = 0; 234 private Map<String, String> result = new ArrayMap<>(); 235 DigestMessageParser(String message)236 public DigestMessageParser(String message) { 237 this.message = message; 238 } 239 240 @Nullable parse()241 public Map<String, String> parse() { 242 try { 243 while (position < message.length()) { 244 parsePair(); 245 if (position != message.length()) { 246 expect(','); 247 } 248 } 249 } catch (IndexOutOfBoundsException e) { 250 VvmLog.e(TAG, e.toString()); 251 return null; 252 } 253 return result; 254 } 255 parsePair()256 private void parsePair() { 257 String key = parseKey(); 258 expect('='); 259 String value = parseValue(); 260 result.put(key, value); 261 } 262 expect(char c)263 private void expect(char c) { 264 if (pop() != c) { 265 throw new IllegalStateException("unexpected character " + message.charAt(position)); 266 } 267 } 268 pop()269 private char pop() { 270 char result = peek(); 271 position++; 272 return result; 273 } 274 peek()275 private char peek() { 276 return message.charAt(position); 277 } 278 goToNext(char c)279 private void goToNext(char c) { 280 while (peek() != c) { 281 position++; 282 } 283 } 284 parseKey()285 private String parseKey() { 286 int start = position; 287 goToNext('='); 288 return message.substring(start, position); 289 } 290 parseValue()291 private String parseValue() { 292 if (peek() == '"') { 293 return parseQuotedValue(); 294 } else { 295 return parseUnquotedValue(); 296 } 297 } 298 parseQuotedValue()299 private String parseQuotedValue() { 300 expect('"'); 301 StringBuilder result = new StringBuilder(); 302 while (true) { 303 char c = pop(); 304 if (c == '\\') { 305 result.append(pop()); 306 } else if (c == '"') { 307 break; 308 } else { 309 result.append(c); 310 } 311 } 312 return result.toString(); 313 } 314 parseUnquotedValue()315 private String parseUnquotedValue() { 316 StringBuilder result = new StringBuilder(); 317 while (true) { 318 char c = pop(); 319 if (c == '\\') { 320 result.append(pop()); 321 } else if (c == ',') { 322 position--; 323 break; 324 } else { 325 result.append(c); 326 } 327 328 if (position == message.length()) { 329 break; 330 } 331 } 332 return result.toString(); 333 } 334 } 335 } 336