/* * Copyright (C) 2015 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.voicemailomtp.mail.store; import android.util.ArraySet; import android.util.Base64; import com.android.voicemailomtp.mail.AuthenticationFailedException; import com.android.voicemailomtp.mail.CertificateValidationException; import com.android.voicemailomtp.mail.MailTransport; import com.android.voicemailomtp.mail.MessagingException; import com.android.voicemailomtp.mail.store.ImapStore.ImapException; import com.android.voicemailomtp.mail.store.imap.DigestMd5Utils; import com.android.voicemailomtp.mail.store.imap.ImapConstants; import com.android.voicemailomtp.mail.store.imap.ImapResponse; import com.android.voicemailomtp.mail.store.imap.ImapResponseParser; import com.android.voicemailomtp.mail.store.imap.ImapUtility; import com.android.voicemailomtp.mail.utils.LogUtils; import com.android.voicemailomtp.OmtpEvents; import com.android.voicemailomtp.VvmLog; 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; import javax.net.ssl.SSLException; /** * A cacheable class that stores the details for a single IMAP connection. */ public class ImapConnection { private final String TAG = "ImapConnection"; private String mLoginPhrase; private ImapStore mImapStore; private MailTransport mTransport; private ImapResponseParser mParser; private Set mCapabilities = new ArraySet<>(); static final String IMAP_REDACTED_LOG = "[IMAP command redacted]"; /** * Next tag to use. All connections associated to the same ImapStore instance share the same * counter to make tests simpler. * (Some of the tests involve multiple connections but only have a single counter to track the * tag.) */ private final AtomicInteger mNextCommandTag = new AtomicInteger(0); ImapConnection(ImapStore store) { setStore(store); } void setStore(ImapStore store) { // TODO: maybe we should throw an exception if the connection is not closed here, // if it's not currently closed, then we won't reopen it, so if the credentials have // changed, the connection will not be reestablished. mImapStore = store; mLoginPhrase = null; } /** * Generates and returns the phrase to be used for authentication. This will be a LOGIN with * username and password. * * @return the login command string to sent to the IMAP server */ String getLoginPhrase() { if (mLoginPhrase == null) { if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) { // build the LOGIN string once (instead of over-and-over again.) // apply the quoting here around the built-up password mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " " + ImapUtility.imapQuoted(mImapStore.getPassword()); } } return mLoginPhrase; } public void open() throws IOException, MessagingException { if (mTransport != null && mTransport.isOpen()) { return; } try { // copy configuration into a clean transport, if necessary if (mTransport == null) { mTransport = mImapStore.cloneTransport(); } mTransport.open(); createParser(); // The server should greet us with something like // * OK IMAP4rev1 Server // consume the response before doing anything else. ImapResponse response = mParser.readResponse(false); if (!response.isOk()) { mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_INVALID_INITIAL_SERVER_RESPONSE); throw new MessagingException( MessagingException.AUTHENTICATION_FAILED_OR_SERVER_ERROR, "Invalid server initial response"); } queryCapability(); maybeDoStartTls(); // LOGIN doLogin(); } catch (SSLException e) { LogUtils.d(TAG, "SSLException ", e); mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_SSL_EXCEPTION); throw new CertificateValidationException(e.getMessage(), e); } catch (IOException ioe) { LogUtils.d(TAG, "IOException", ioe); mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_IOE_ON_OPEN); throw ioe; } finally { destroyResponses(); } } void logout() { try { sendCommand(ImapConstants.LOGOUT, false); if (!mParser.readResponse(true).is(0, ImapConstants.BYE)) { VvmLog.e(TAG, "Server did not respond LOGOUT with BYE"); } if (!mParser.readResponse(false).isOk()) { VvmLog.e(TAG, "Server did not respond OK after LOGOUT"); } } catch (IOException | MessagingException e) { VvmLog.e(TAG, "Error while logging out:" + e); } } /** * Closes the connection and releases all resources. This connection can not be used again * until {@link #setStore(ImapStore)} is called. */ void close() { if (mTransport != null) { logout(); mTransport.close(); mTransport = null; } destroyResponses(); mParser = null; mImapStore = null; } /** * Attempts to convert the connection into secure connection. */ private void maybeDoStartTls() throws IOException, MessagingException { // STARTTLS is required in the OMTP standard but not every implementation support it. // Make sure the server does have this capability if (hasCapability(ImapConstants.CAPABILITY_STARTTLS)) { executeSimpleCommand(ImapConstants.STARTTLS); mTransport.reopenTls(); createParser(); // The cached capabilities should be refreshed after TLS is established. queryCapability(); } } /** * Logs into the IMAP server */ private void doLogin() throws IOException, MessagingException, AuthenticationFailedException { try { if (mCapabilities.contains(ImapConstants.CAPABILITY_AUTH_DIGEST_MD5)) { doDigestMd5Auth(); } else { executeSimpleCommand(getLoginPhrase(), true); } } catch (ImapException ie) { LogUtils.d(TAG, "ImapException", ie); String status = ie.getStatus(); String statusMessage = ie.getStatusMessage(); String alertText = ie.getAlertText(); if (ImapConstants.NO.equals(status)) { switch (statusMessage) { case ImapConstants.NO_UNKNOWN_USER: mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_USER); break; case ImapConstants.NO_UNKNOWN_CLIENT: mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_DEVICE); break; case ImapConstants.NO_INVALID_PASSWORD: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_AUTH_INVALID_PASSWORD); break; case ImapConstants.NO_MAILBOX_NOT_INITIALIZED: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_AUTH_MAILBOX_NOT_INITIALIZED); break; case ImapConstants.NO_SERVICE_IS_NOT_PROVISIONED: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_PROVISIONED); break; case ImapConstants.NO_SERVICE_IS_NOT_ACTIVATED: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_ACTIVATED); break; case ImapConstants.NO_USER_IS_BLOCKED: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_AUTH_USER_IS_BLOCKED); break; case ImapConstants.NO_APPLICATION_ERROR: mImapStore.getImapHelper() .handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE); default: mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_BAD_IMAP_CREDENTIAL); } throw new AuthenticationFailedException(alertText, ie); } mImapStore.getImapHelper().handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE); throw new MessagingException(alertText, ie); } } 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 responses = executeSimpleCommand( ImapConstants.AUTHENTICATE + " " + ImapConstants.AUTH_DIGEST_MD5); String decodedChallenge = decodeBase64(responses.get(0).getStringOrEmpty(0).getString()); Map 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 responses = executeSimpleCommand(ImapConstants.CAPABILITY); mCapabilities.clear(); Set disabledCapabilities = mImapStore.getImapHelper().getConfig() .getDisabledCapabilities(); for (ImapResponse response : responses) { if (response.isTagged()) { continue; } for (int i = 0; i < response.size(); i++) { String capability = response.getStringOrEmpty(i).getString(); if (disabledCapabilities != null) { if (!disabledCapabilities.contains(capability)) { mCapabilities.add(capability); } } else { mCapabilities.add(capability); } } } LogUtils.d(TAG, "Capabilities: " + mCapabilities.toString()); } private boolean hasCapability(String capability) { return mCapabilities.contains(capability); } /** * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and * set it to {@link #mParser}. * * If we already have an {@link ImapResponseParser}, we * {@link #destroyResponses()} and throw it away. */ private void createParser() { destroyResponses(); mParser = new ImapResponseParser(mTransport.getInputStream()); } public void destroyResponses() { if (mParser != null) { mParser.destroyResponses(); } } public ImapResponse readResponse() throws IOException, MessagingException { return mParser.readResponse(false); } public List executeSimpleCommand(String command) throws IOException, MessagingException{ return executeSimpleCommand(command, false); } /** * Send a single command to the server. The command will be preceded by an IMAP command * tag and followed by \r\n (caller need not supply them). * Execute a simple command at the server, a simple command being one that is sent in a single * line of text * * @param command the command to send to the server * @param sensitive whether the command should be redacted in logs (used for login) * @return a list of ImapResponses * @throws IOException * @throws MessagingException */ public List executeSimpleCommand(String command, boolean sensitive) throws IOException, MessagingException { // TODO: It may be nice to catch IOExceptions and close the connection here. // Currently, we expect callers to do that, but if they fail to we'll be in a broken state. sendCommand(command, sensitive); return getCommandResponses(); } public String sendCommand(String command, boolean sensitive) throws IOException, MessagingException { open(); if (mTransport == null) { throw new IOException("Null transport"); } String tag = Integer.toString(mNextCommandTag.incrementAndGet()); String commandToSend = tag + " " + command; mTransport.writeLine(commandToSend, (sensitive ? IMAP_REDACTED_LOG : command)); return tag; } List 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 * * @return a list of ImapResponses * @throws IOException * @throws MessagingException */ List getCommandResponses() throws IOException, MessagingException { final List responses = new ArrayList(); ImapResponse response; do { response = mParser.readResponse(false); responses.add(response); } while (!(response.isTagged() || response.isContinuationRequest())); if (!(response.isOk() || response.isContinuationRequest())) { final String toString = response.toString(); final String status = response.getStatusOrEmpty().getString(); final String statusMessage = response.getStatusResponseTextOrEmpty().getString(); final String alert = response.getAlertTextOrEmpty().getString(); final String responseCode = response.getResponseCodeOrEmpty().getString(); destroyResponses(); throw new ImapException(toString, status, statusMessage, alert, responseCode); } return responses; } }