Home | History | Annotate | Download | only in imap
      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
     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 builder = new StringBuilder();
    125 
    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 
    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
    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
    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 message;
    233     private int position = 0;
    234     private Map<String, String> result = new ArrayMap<>();
    235 
    236     public DigestMessageParser(String message) {
    237       this.message = message;
    238     }
    239 
    240     @Nullable
    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 
    256     private void parsePair() {
    257       String key = parseKey();
    258       expect('=');
    259       String value = parseValue();
    260       result.put(key, value);
    261     }
    262 
    263     private void expect(char c) {
    264       if (pop() != c) {
    265         throw new IllegalStateException("unexpected character " + message.charAt(position));
    266       }
    267     }
    268 
    269     private char pop() {
    270       char result = peek();
    271       position++;
    272       return result;
    273     }
    274 
    275     private char peek() {
    276       return message.charAt(position);
    277     }
    278 
    279     private void goToNext(char c) {
    280       while (peek() != c) {
    281         position++;
    282       }
    283     }
    284 
    285     private String parseKey() {
    286       int start = position;
    287       goToNext('=');
    288       return message.substring(start, position);
    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           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