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 @SuppressWarnings("AndroidApiChecker") // Map.getOrDefault() is java8
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 mBuilder = new StringBuilder();
125 
appendQuoted(String key, String value)126       public ResponseBuilder appendQuoted(String key, String value) {
127         if (mBuilder.length() != 0) {
128           mBuilder.append(",");
129         }
130         mBuilder.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 (mBuilder.length() != 0) {
136           mBuilder.append(",");
137         }
138         mBuilder.append(key).append("=").append(value);
139         return this;
140       }
141 
142       @Override
toString()143       public String toString() {
144         return mBuilder.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 mMessage;
233     private int mPosition = 0;
234     private Map<String, String> mResult = new ArrayMap<>();
235 
DigestMessageParser(String message)236     public DigestMessageParser(String message) {
237       mMessage = message;
238     }
239 
240     @Nullable
parse()241     public Map<String, String> parse() {
242       try {
243         while (mPosition < mMessage.length()) {
244           parsePair();
245           if (mPosition != mMessage.length()) {
246             expect(',');
247           }
248         }
249       } catch (IndexOutOfBoundsException e) {
250         VvmLog.e(TAG, e.toString());
251         return null;
252       }
253       return mResult;
254     }
255 
parsePair()256     private void parsePair() {
257       String key = parseKey();
258       expect('=');
259       String value = parseValue();
260       mResult.put(key, value);
261     }
262 
expect(char c)263     private void expect(char c) {
264       if (pop() != c) {
265         throw new IllegalStateException("unexpected character " + mMessage.charAt(mPosition));
266       }
267     }
268 
pop()269     private char pop() {
270       char result = peek();
271       mPosition++;
272       return result;
273     }
274 
peek()275     private char peek() {
276       return mMessage.charAt(mPosition);
277     }
278 
goToNext(char c)279     private void goToNext(char c) {
280       while (peek() != c) {
281         mPosition++;
282       }
283     }
284 
parseKey()285     private String parseKey() {
286       int start = mPosition;
287       goToNext('=');
288       return mMessage.substring(start, mPosition);
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           mPosition--;
323           break;
324         } else {
325           result.append(c);
326         }
327 
328         if (mPosition == mMessage.length()) {
329           break;
330         }
331       }
332       return result.toString();
333     }
334   }
335 }
336