Support AUTHENTICATE DIGEST-MD5 for OMTP visual voicemail

As specified in https://www.ietf.org/rfc/rfc2831.txt

+ use the SSL port in the carrier config for IMAP SSL connection
+ avoid server capabilities that is disabled in carrier config.
+ Additional config for T-Mobile

Fixes:27816895
Change-Id: Ic3d29f3ea388242d37646e9c9c76f14ee54e41c2
(cherry picked from commit 44cabbb246ed3ce7830a4730d941c4355d569ed0)
diff --git a/res/xml/vvm_config.xml b/res/xml/vvm_config.xml
index 83ff056..57a1050 100644
--- a/res/xml/vvm_config.xml
+++ b/res/xml/vvm_config.xml
@@ -85,6 +85,7 @@
     </string-array>
     <string name="vvm_type_string">vvm_type_cvvm</string>>
     <string-array name="vvm_disabled_capabilities_string_array">
+      <!-- b/28717550 -->
       <item value="AUTH=DIGEST-MD5"/>
     </string-array>
   </pbundle_as_map>
diff --git a/src/com/android/phone/common/mail/MailTransport.java b/src/com/android/phone/common/mail/MailTransport.java
index 7d5cc20..cc09044 100644
--- a/src/com/android/phone/common/mail/MailTransport.java
+++ b/src/com/android/phone/common/mail/MailTransport.java
@@ -290,6 +290,10 @@
         mSocket = null;
     }
 
+    public String getHost() {
+        return mHost;
+    }
+
     public InputStream getInputStream() {
         return mIn;
     }
diff --git a/src/com/android/phone/common/mail/store/ImapConnection.java b/src/com/android/phone/common/mail/store/ImapConnection.java
index 58f0f76..914ab10 100644
--- a/src/com/android/phone/common/mail/store/ImapConnection.java
+++ b/src/com/android/phone/common/mail/store/ImapConnection.java
@@ -18,12 +18,14 @@
 import android.provider.VoicemailContract.Status;
 import android.text.TextUtils;
 import android.util.ArraySet;
+import android.util.Base64;
 
 import com.android.phone.common.mail.AuthenticationFailedException;
 import com.android.phone.common.mail.CertificateValidationException;
 import com.android.phone.common.mail.MailTransport;
 import com.android.phone.common.mail.MessagingException;
 import com.android.phone.common.mail.store.ImapStore.ImapException;
+import com.android.phone.common.mail.store.imap.DigestMd5Utils;
 import com.android.phone.common.mail.store.imap.ImapConstants;
 import com.android.phone.common.mail.store.imap.ImapResponse;
 import com.android.phone.common.mail.store.imap.ImapResponseParser;
@@ -33,6 +35,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -171,7 +174,11 @@
      */
     private void doLogin() throws IOException, MessagingException, AuthenticationFailedException {
         try {
-            executeSimpleCommand(getLoginPhrase(), true);
+            if (mCapabilities.contains(ImapConstants.CAPABILITY_AUTH_DIGEST_MD5)) {
+                doDigestMd5Auth();
+            } else {
+                executeSimpleCommand(getLoginPhrase(), true);
+            }
         } catch (ImapException ie) {
             LogUtils.d(TAG, "ImapException", ie);
             final String status = ie.getStatus();
@@ -191,15 +198,71 @@
         }
     }
 
+    private void doDigestMd5Auth() throws IOException, MessagingException {
+
+        //  Initiate the authentication.
+        //  The server will issue us a challenge, asking to run MD5 on the nonce with our password
+        //  and other data, including the cnonce we randomly generated.
+        //
+        //  C: a AUTHENTICATE DIGEST-MD5
+        //  S: (BASE64) realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth",
+        //             algorithm=md5-sess,charset=utf-8
+        List<ImapResponse> responses = executeSimpleCommand(
+            ImapConstants.AUTHENTICATE + " " + ImapConstants.AUTH_DIGEST_MD5);
+        String decodedChallenge = decodeBase64(responses.get(0).getStringOrEmpty(0).getString());
+
+        Map<String, String> challenge = DigestMd5Utils.parseDigestMessage(decodedChallenge);
+        DigestMd5Utils.Data data = new DigestMd5Utils.Data(mImapStore, mTransport, challenge);
+
+        String response = data.createResponse();
+        //  Respond to the challenge. If the server accepts it, it will reply a response-auth which
+        //  is the MD5 of our password and the cnonce we've provided, to prove the server does know
+        //  the password.
+        //
+        //  C: (BASE64) charset=utf-8,username="chris",realm="elwood.innosoft.com",
+        //              nonce="OA6MG9tEQGm2hh",nc=00000001,cnonce="OA6MHXh6VqTrRk",
+        //              digest-uri="imap/elwood.innosoft.com",
+        //              response=d388dad90d4bbd760a152321f2143af7,qop=auth
+        //  S: (BASE64) rspauth=ea40f60335c427b5527b84dbabcdfffd
+
+        responses = executeContinuationResponse(encodeBase64(response), true);
+
+        // Verify response-auth.
+        // If failed verifyResponseAuth() will throw a MessagingException, terminating the
+        // connection
+        String decodedResponseAuth = decodeBase64(responses.get(0).getStringOrEmpty(0).getString());
+        data.verifyResponseAuth(decodedResponseAuth);
+
+        //  Send a empty response to indicate we've accepted the response-auth
+        //
+        //  C: (empty)
+        //  S: a OK User logged in
+        executeContinuationResponse("", false);
+
+    }
+
+    private static String decodeBase64(String string) {
+        return new String(Base64.decode(string, Base64.DEFAULT));
+    }
+
+    private static String encodeBase64(String string) {
+        return Base64.encodeToString(string.getBytes(), Base64.NO_WRAP);
+    }
+
     private void queryCapability() throws IOException, MessagingException {
         List<ImapResponse> responses = executeSimpleCommand(ImapConstants.CAPABILITY);
         mCapabilities.clear();
+        Set<String> disabledCapabilities = mImapStore.getImapHelper().getConfig()
+                .getDisabledCapabilities();
         for (ImapResponse response : responses) {
             if (response.isTagged()) {
                 continue;
             }
             for (int i = 0; i < response.size(); i++) {
-                mCapabilities.add(response.getStringOrEmpty(i).getString());
+                String capability = response.getStringOrEmpty(i).getString();
+                if (disabledCapabilities != null && !disabledCapabilities.contains(capability)) {
+                    mCapabilities.add(capability);
+                }
             }
         }
 
@@ -270,6 +333,12 @@
         return tag;
     }
 
+    List<ImapResponse> executeContinuationResponse(String response, boolean sensitive)
+            throws IOException, MessagingException {
+        mTransport.writeLine(response, (sensitive ? IMAP_REDACTED_LOG : response));
+        return getCommandResponses();
+    }
+
     /**
      * Read and return all of the responses from the most recent command sent to the server
      *
@@ -283,9 +352,9 @@
         do {
             response = mParser.readResponse();
             responses.add(response);
-        } while (!response.isTagged());
+        } while (!(response.isTagged() || response.isContinuationRequest()));
 
-        if (!response.isOk()) {
+        if (!(response.isOk() || response.isContinuationRequest())) {
             final String toString = response.toString();
             final String status = response.getStatusOrEmpty().getString();
             final String alert = response.getAlertTextOrEmpty().getString();
diff --git a/src/com/android/phone/common/mail/store/imap/DigestMd5Utils.java b/src/com/android/phone/common/mail/store/imap/DigestMd5Utils.java
new file mode 100644
index 0000000..e6376a3
--- /dev/null
+++ b/src/com/android/phone/common/mail/store/imap/DigestMd5Utils.java
@@ -0,0 +1,333 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.phone.common.mail.store.imap;
+
+import android.annotation.Nullable;
+import android.util.ArrayMap;
+import android.util.Base64;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.phone.common.mail.MailTransport;
+import com.android.phone.common.mail.MessagingException;
+import com.android.phone.common.mail.store.ImapStore;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Map;
+
+public class DigestMd5Utils {
+
+    private static final String TAG = "DigestMd5Utils";
+
+    private static final String DIGEST_CHARSET = "CHARSET";
+    private static final String DIGEST_USERNAME = "username";
+    private static final String DIGEST_REALM = "realm";
+    private static final String DIGEST_NONCE = "nonce";
+    private static final String DIGEST_NC = "nc";
+    private static final String DIGEST_CNONCE = "cnonce";
+    private static final String DIGEST_URI = "digest-uri";
+    private static final String DIGEST_RESPONSE = "response";
+    private static final String DIGEST_QOP = "qop";
+
+    private static final String RESPONSE_AUTH_HEADER = "rspauth=";
+    private static final String HEX_CHARS = "0123456789abcdef";
+
+    /**
+     * Represents the set of data we need to generate the DIGEST-MD5 response.
+     */
+    public static class Data {
+
+        private static final String CHARSET = "utf-8'";
+
+        public String username;
+        public String password;
+        public String realm;
+        public String nonce;
+        public String nc;
+        public String cnonce;
+        public String digestUri;
+        public String qop;
+
+        @VisibleForTesting
+        Data() {
+            // Do nothing
+        }
+
+        public Data(ImapStore imapStore, MailTransport transport, Map<String, String> challenge) {
+            username = imapStore.getUsername();
+            password = imapStore.getPassword();
+            realm = challenge.getOrDefault(DIGEST_REALM, "");
+            nonce = challenge.get(DIGEST_NONCE);
+            cnonce = createCnonce();
+            nc = "00000001"; // Subsequent Authentication not supported, nounce count always 1.
+            qop = "auth"; // Other config not supported
+            digestUri = "imap/" + transport.getHost();
+        }
+
+        private static String createCnonce() {
+            SecureRandom generator = new SecureRandom();
+
+            // At least 64 bits of entropy is required
+            byte[] rawBytes = new byte[8];
+            generator.nextBytes(rawBytes);
+
+            return Base64.encodeToString(rawBytes, Base64.NO_WRAP);
+        }
+
+        /**
+         * Verify the response-auth returned by the server is correct.
+         */
+        public void verifyResponseAuth(String response)
+                throws MessagingException {
+            if (!response.startsWith(RESPONSE_AUTH_HEADER)) {
+                throw new MessagingException("response-auth expected");
+            }
+            if (!response.substring(RESPONSE_AUTH_HEADER.length())
+                    .equals(DigestMd5Utils.getResponse(this, true))) {
+                throw new MessagingException("invalid response-auth return from the server.");
+            }
+        }
+
+        public String createResponse() {
+            String response = getResponse(this, false);
+            ResponseBuilder builder = new ResponseBuilder();
+            builder
+                    .append(DIGEST_CHARSET, CHARSET)
+                    .appendQuoted(DIGEST_USERNAME, username)
+                    .appendQuoted(DIGEST_REALM, realm)
+                    .appendQuoted(DIGEST_NONCE, nonce)
+                    .append(DIGEST_NC, nc)
+                    .appendQuoted(DIGEST_CNONCE, cnonce)
+                    .appendQuoted(DIGEST_URI, digestUri)
+                    .append(DIGEST_RESPONSE, response)
+                    .append(DIGEST_QOP, qop);
+            return builder.toString();
+        }
+
+        private static class ResponseBuilder {
+
+            private StringBuilder mBuilder = new StringBuilder();
+
+            public ResponseBuilder appendQuoted(String key, String value) {
+                if (mBuilder.length() != 0) {
+                    mBuilder.append(",");
+                }
+                mBuilder.append(key).append("=\"").append(value).append("\"");
+                return this;
+            }
+
+            public ResponseBuilder append(String key, String value) {
+                if (mBuilder.length() != 0) {
+                    mBuilder.append(",");
+                }
+                mBuilder.append(key).append("=").append(value);
+                return this;
+            }
+
+            @Override
+            public String toString() {
+                return mBuilder.toString();
+            }
+        }
+    }
+
+    /*
+        response-value  =
+            toHex( getKeyDigest ( toHex(getMd5(a1)),
+            { nonce-value, ":" nc-value, ":",
+              cnonce-value, ":", qop-value, ":", toHex(getMd5(a2)) }))
+     * @param isResponseAuth is the response the one the server is returning us. response-auth has
+     * different a2 format.
+     */
+    @VisibleForTesting
+    static String getResponse(Data data, boolean isResponseAuth) {
+        StringBuilder a1 = new StringBuilder();
+        a1.append(new String(
+                getMd5(data.username + ":" + data.realm + ":" + data.password),
+                StandardCharsets.ISO_8859_1));
+        a1.append(":").append(data.nonce).append(":").append(data.cnonce);
+
+        StringBuilder a2 = new StringBuilder();
+        if (!isResponseAuth) {
+            a2.append("AUTHENTICATE");
+        }
+        a2.append(":").append(data.digestUri);
+
+        return toHex(getKeyDigest(
+                toHex(getMd5(a1.toString())),
+                data.nonce + ":" + data.nc + ":" + data.cnonce + ":" + data.qop + ":" + toHex(
+                        getMd5(a2.toString()))
+        ));
+    }
+
+    /**
+     * Let getMd5(s) be the 16 octet MD5 hash [RFC 1321] of the octet string s.
+     */
+    private static byte[] getMd5(String s) {
+        try {
+            MessageDigest digester = MessageDigest.getInstance("MD5");
+            digester.update(s.getBytes(StandardCharsets.ISO_8859_1));
+            return digester.digest();
+        } catch (NoSuchAlgorithmException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    /**
+     * Let getKeyDigest(k, s) be getMd5({k, ":", s}), i.e., the 16 octet hash of the string k, a colon and the
+     * string s.
+     */
+    private static byte[] getKeyDigest(String k, String s) {
+        StringBuilder builder = new StringBuilder(k).append(":").append(s);
+        return getMd5(builder.toString());
+    }
+
+    /**
+     * Let toHex(n) be the representation of the 16 octet MD5 hash n as a string of 32 hex digits
+     * (with alphabetic characters always in lower case, since MD5 is case sensitive).
+     */
+    private static String toHex(byte[] n) {
+        StringBuilder result = new StringBuilder();
+        for (byte b : n) {
+            int unsignedByte = b & 0xFF;
+            result.append(HEX_CHARS.charAt(unsignedByte / 16))
+                    .append(HEX_CHARS.charAt(unsignedByte % 16));
+        }
+        return result.toString();
+    }
+
+    public static Map<String, String> parseDigestMessage(String message) throws MessagingException {
+        Map<String, String> result = new DigestMessageParser(message).parse();
+        if (!result.containsKey(DIGEST_NONCE)) {
+            throw new MessagingException("nonce missing from server DIGEST-MD5 challenge");
+        }
+        return result;
+    }
+
+    /**
+     * Parse the key-value pair returned by the server.
+     */
+    private static class DigestMessageParser {
+
+        private final String mMessage;
+        private int mPosition = 0;
+        private Map<String, String> mResult = new ArrayMap<>();
+
+        public DigestMessageParser(String message) {
+            mMessage = message;
+        }
+
+        @Nullable
+        public Map<String, String> parse() {
+            try {
+                while (mPosition < mMessage.length()) {
+                    parsePair();
+                    if (mPosition != mMessage.length()) {
+                        expect(',');
+                    }
+                }
+            } catch (IndexOutOfBoundsException e) {
+                Log.e(TAG, e.toString());
+                return null;
+            }
+            return mResult;
+        }
+
+        private void parsePair() {
+            String key = parseKey();
+            expect('=');
+            String value = parseValue();
+            mResult.put(key, value);
+        }
+
+        private void expect(char c) {
+            if (pop() != c) {
+                throw new IllegalStateException(
+                        "unexpected character " + mMessage.charAt(mPosition));
+            }
+        }
+
+        private char pop() {
+            char result = peek();
+            mPosition++;
+            return result;
+        }
+
+        private char peek() {
+            return mMessage.charAt(mPosition);
+        }
+
+        private void goToNext(char c) {
+            while (peek() != c) {
+                mPosition++;
+            }
+        }
+
+        private String parseKey() {
+            int start = mPosition;
+            goToNext('=');
+            return mMessage.substring(start, mPosition);
+        }
+
+        private String parseValue() {
+            if (peek() == '"') {
+                return parseQuotedValue();
+            } else {
+                return parseUnquotedValue();
+            }
+        }
+
+        private String parseQuotedValue() {
+            expect('"');
+            StringBuilder result = new StringBuilder();
+            while (true) {
+                char c = pop();
+                if (c == '\\') {
+                    result.append(pop());
+                } else if (c == '"') {
+                    break;
+                } else {
+                    result.append(c);
+                }
+            }
+            return result.toString();
+        }
+
+        private String parseUnquotedValue() {
+            StringBuilder result = new StringBuilder();
+            while (true) {
+                char c = pop();
+                if (c == '\\') {
+                    result.append(pop());
+                } else if (c == ',') {
+                    mPosition--;
+                    break;
+                } else {
+                    result.append(c);
+                }
+
+                if (mPosition == mMessage.length()) {
+                    break;
+                }
+            }
+            return result.toString();
+        }
+    }
+}
diff --git a/src/com/android/phone/common/mail/store/imap/ImapConstants.java b/src/com/android/phone/common/mail/store/imap/ImapConstants.java
index a04d584..9e6e247 100644
--- a/src/com/android/phone/common/mail/store/imap/ImapConstants.java
+++ b/src/com/android/phone/common/mail/store/imap/ImapConstants.java
@@ -113,5 +113,11 @@
     /**
      * capabilities
      */
+    public static final String CAPABILITY_AUTH_DIGEST_MD5 = "AUTH=DIGEST-MD5";
     public static final String CAPABILITY_STARTTLS = "STARTTLS";
+
+    /**
+     * authentication
+     */
+    public static final String AUTH_DIGEST_MD5 = "DIGEST-MD5";
 }
\ No newline at end of file
diff --git a/src/com/android/phone/vvm/omtp/OmtpVvmCarrierConfigHelper.java b/src/com/android/phone/vvm/omtp/OmtpVvmCarrierConfigHelper.java
index c8b37fd..df706d5 100644
--- a/src/com/android/phone/vvm/omtp/OmtpVvmCarrierConfigHelper.java
+++ b/src/com/android/phone/vvm/omtp/OmtpVvmCarrierConfigHelper.java
@@ -65,8 +65,19 @@
             CarrierConfigManager.KEY_VVM_PREFETCH_BOOL;
     static final String KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL =
             CarrierConfigManager.KEY_VVM_CELLULAR_DATA_REQUIRED_BOOL;
+
+    /**
+     * @see #getSslPort()
+     */
     static final String KEY_VVM_SSL_PORT_NUMBER_INT =
             "vvm_ssl_port_number_int";
+
+    /**
+     * Ban a capability reported by the server from being used. The array of string should be a
+     * subset of the capabilities returned IMAP CAPABILITY command.
+     *
+     * @see #getDisabledCapabilities()
+     */
     static final String KEY_VVM_DISABLED_CAPABILITIES_STRING_ARRAY =
             "vvm_disabled_capabilities_string_array";
     static final String KEY_VVM_CLIENT_PREFIX_STRING =
@@ -188,7 +199,6 @@
      *
      * TODO: make config public and add to CarrierConfigManager
      */
-    @VisibleForTesting // TODO: remove after method used.
     public int getSslPort() {
         Integer port = (Integer) getValue(KEY_VVM_SSL_PORT_NUMBER_INT);
         if (port != null) {
@@ -200,10 +210,13 @@
     /**
      * Hidden Config.
      *
+     * <p>Sometimes the server states it supports a certain feature but we found they have bug on
+     * the server side. For example, in b/28717550 the server reported AUTH=DIGEST-MD5 capability
+     * but using it to login will cause subsequent response to be erroneous.
+     *
      * @return A set of capabilities that is reported by the IMAP CAPABILITY command, but determined
      * to have issues and should not be used.
      */
-    @VisibleForTesting // TODO: remove after method used.
     @Nullable
     public Set<String> getDisabledCapabilities() {
         Set<String> disabledCapabilities = getDisabledCapabilities(mCarrierConfig);
diff --git a/src/com/android/phone/vvm/omtp/imap/ImapHelper.java b/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
index 2c10377..404c771 100644
--- a/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
+++ b/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
@@ -25,7 +25,6 @@
 import android.provider.VoicemailContract.Status;
 import android.telecom.PhoneAccountHandle;
 import android.telecom.Voicemail;
-import android.telephony.TelephonyManager;
 import android.util.Base64;
 import android.util.Log;
 
@@ -80,10 +79,14 @@
     private int mQuotaOccupied;
     private int mQuotaTotal;
 
+    private final OmtpVvmCarrierConfigHelper mConfig;
+
     public ImapHelper(Context context, PhoneAccountHandle phoneAccount, Network network) {
         mContext = context;
         mPhoneAccount = phoneAccount;
         mNetwork = network;
+        mConfig = new OmtpVvmCarrierConfigHelper(context,
+                PhoneUtils.getSubIdForPhoneAccountHandle(phoneAccount));
         try {
             TempDirectory.setTempDirectory(context);
 
@@ -98,11 +101,9 @@
                             OmtpConstants.IMAP_PORT, phoneAccount));
             int auth = ImapStore.FLAG_NONE;
 
-            OmtpVvmCarrierConfigHelper carrierConfigHelper = new OmtpVvmCarrierConfigHelper(context,
-                    PhoneUtils.getSubIdForPhoneAccountHandle(phoneAccount));
-            if (TelephonyManager.VVM_TYPE_CVVM.equals(carrierConfigHelper.getVvmType())) {
-                // TODO: move these into the carrier config app
-                port = 993;
+            int sslPort = mConfig.getSslPort();
+            if (sslPort != 0) {
+                port = sslPort;
                 auth = ImapStore.FLAG_SSL;
             }
 
@@ -142,6 +143,10 @@
         return info.isRoaming();
     }
 
+    public OmtpVvmCarrierConfigHelper getConfig() {
+        return mConfig;
+    }
+
     /** The caller thread will block until the method returns. */
     public boolean markMessagesAsRead(List<Voicemail> voicemails) {
         return setFlags(voicemails, Flag.SEEN);
diff --git a/tests/src/com/android/phone/common/mail/store/imap/DigestMd5UtilsTest.java b/tests/src/com/android/phone/common/mail/store/imap/DigestMd5UtilsTest.java
new file mode 100644
index 0000000..5534632
--- /dev/null
+++ b/tests/src/com/android/phone/common/mail/store/imap/DigestMd5UtilsTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.phone.common.mail.store.imap;
+
+import junit.framework.TestCase;
+
+public class DigestMd5UtilsTest extends TestCase {
+
+    public void testGetResponse() {
+        // Example data from RFC 2831.4
+        DigestMd5Utils.Data data = new DigestMd5Utils.Data();
+        data.username = "chris";
+        data.password = "secret";
+        data.realm = "elwood.innosoft.com";
+        data.nonce = "OA6MG9tEQGm2hh";
+        data.cnonce = "OA6MHXh6VqTrRk";
+        data.nc = "00000001";
+        data.qop = "auth";
+        data.digestUri = "imap/elwood.innosoft.com";
+        String response = DigestMd5Utils.getResponse(data, false);
+        assertEquals("d388dad90d4bbd760a152321f2143af7", response);
+    }
+
+    public void testGetResponse_ResponseAuth() {
+        // Example data from RFC 2831.4
+        DigestMd5Utils.Data data = new DigestMd5Utils.Data();
+        data.username = "chris";
+        data.password = "secret";
+        data.realm = "elwood.innosoft.com";
+        data.nonce = "OA6MG9tEQGm2hh";
+        data.cnonce = "OA6MHXh6VqTrRk";
+        data.nc = "00000001";
+        data.qop = "auth";
+        data.digestUri = "imap/elwood.innosoft.com";
+        String response = DigestMd5Utils.getResponse(data, true);
+        assertEquals("ea40f60335c427b5527b84dbabcdfffd", response);
+    }
+
+}