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