blob: e6376a316c4ff18124f219e534d6d9ef37aa137a [file] [log] [blame]
/*
* 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();
}
}
}