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.voicemail.impl.mail.store.imap;
18
19import android.annotation.TargetApi;
20import android.os.Build.VERSION_CODES;
21import android.support.annotation.Nullable;
22import android.support.annotation.VisibleForTesting;
23import android.util.ArrayMap;
24import android.util.Base64;
25import com.android.voicemail.impl.VvmLog;
26import com.android.voicemail.impl.mail.MailTransport;
27import com.android.voicemail.impl.mail.MessagingException;
28import com.android.voicemail.impl.mail.store.ImapStore;
29import java.nio.charset.StandardCharsets;
30import java.security.MessageDigest;
31import java.security.NoSuchAlgorithmException;
32import java.security.SecureRandom;
33import java.util.Map;
34
35@SuppressWarnings("AndroidApiChecker") // Map.getOrDefault() is java8
36@TargetApi(VERSION_CODES.O)
37public 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
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    /** Verify the response-auth returned by the server is correct. */
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
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
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
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
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
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. */
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   */
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   */
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
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
236    public DigestMessageParser(String message) {
237      mMessage = message;
238    }
239
240    @Nullable
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
256    private void parsePair() {
257      String key = parseKey();
258      expect('=');
259      String value = parseValue();
260      mResult.put(key, value);
261    }
262
263    private void expect(char c) {
264      if (pop() != c) {
265        throw new IllegalStateException("unexpected character " + mMessage.charAt(mPosition));
266      }
267    }
268
269    private char pop() {
270      char result = peek();
271      mPosition++;
272      return result;
273    }
274
275    private char peek() {
276      return mMessage.charAt(mPosition);
277    }
278
279    private void goToNext(char c) {
280      while (peek() != c) {
281        mPosition++;
282      }
283    }
284
285    private String parseKey() {
286      int start = mPosition;
287      goToNext('=');
288      return mMessage.substring(start, mPosition);
289    }
290
291    private String parseValue() {
292      if (peek() == '"') {
293        return parseQuotedValue();
294      } else {
295        return parseUnquotedValue();
296      }
297    }
298
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
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