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);
+ }
+
+}