1 /*
2  * Copyright (C) 2015 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 package com.android.voicemail.impl.mail.store;
17 
18 import android.util.ArraySet;
19 import android.util.Base64;
20 import com.android.voicemail.impl.OmtpEvents;
21 import com.android.voicemail.impl.VvmLog;
22 import com.android.voicemail.impl.mail.AuthenticationFailedException;
23 import com.android.voicemail.impl.mail.CertificateValidationException;
24 import com.android.voicemail.impl.mail.MailTransport;
25 import com.android.voicemail.impl.mail.MessagingException;
26 import com.android.voicemail.impl.mail.store.ImapStore.ImapException;
27 import com.android.voicemail.impl.mail.store.imap.DigestMd5Utils;
28 import com.android.voicemail.impl.mail.store.imap.ImapConstants;
29 import com.android.voicemail.impl.mail.store.imap.ImapResponse;
30 import com.android.voicemail.impl.mail.store.imap.ImapResponseParser;
31 import com.android.voicemail.impl.mail.store.imap.ImapUtility;
32 import com.android.voicemail.impl.mail.utils.LogUtils;
33 import java.io.IOException;
34 import java.util.ArrayList;
35 import java.util.List;
36 import java.util.Map;
37 import java.util.Set;
38 import java.util.concurrent.atomic.AtomicInteger;
39 import javax.net.ssl.SSLException;
40 
41 /** A cacheable class that stores the details for a single IMAP connection. */
42 public class ImapConnection {
43   private final String TAG = "ImapConnection";
44 
45   private String loginPhrase;
46   private ImapStore imapStore;
47   private MailTransport transport;
48   private ImapResponseParser parser;
49   private Set<String> capabilities = new ArraySet<>();
50 
51   static final String IMAP_REDACTED_LOG = "[IMAP command redacted]";
52 
53   /**
54    * Next tag to use. All connections associated to the same ImapStore instance share the same
55    * counter to make tests simpler. (Some of the tests involve multiple connections but only have a
56    * single counter to track the tag.)
57    */
58   private final AtomicInteger nextCommandTag = new AtomicInteger(0);
59 
ImapConnection(ImapStore store)60   ImapConnection(ImapStore store) {
61     setStore(store);
62   }
63 
setStore(ImapStore store)64   void setStore(ImapStore store) {
65     // TODO: maybe we should throw an exception if the connection is not closed here,
66     // if it's not currently closed, then we won't reopen it, so if the credentials have
67     // changed, the connection will not be reestablished.
68     imapStore = store;
69     loginPhrase = null;
70   }
71 
72   /**
73    * Generates and returns the phrase to be used for authentication. This will be a LOGIN with
74    * username and password.
75    *
76    * @return the login command string to sent to the IMAP server
77    */
getLoginPhrase()78   String getLoginPhrase() {
79     if (loginPhrase == null) {
80       if (imapStore.getUsername() != null && imapStore.getPassword() != null) {
81         // build the LOGIN string once (instead of over-and-over again.)
82         // apply the quoting here around the built-up password
83         loginPhrase =
84             ImapConstants.LOGIN
85                 + " "
86                 + imapStore.getUsername()
87                 + " "
88                 + ImapUtility.imapQuoted(imapStore.getPassword());
89       }
90     }
91     return loginPhrase;
92   }
93 
open()94   public void open() throws IOException, MessagingException {
95     if (transport != null && transport.isOpen()) {
96       return;
97     }
98 
99     try {
100       // copy configuration into a clean transport, if necessary
101       if (transport == null) {
102         transport = imapStore.cloneTransport();
103       }
104 
105       transport.open();
106 
107       createParser();
108 
109       // The server should greet us with something like
110       // * OK IMAP4rev1 Server
111       // consume the response before doing anything else.
112       ImapResponse response = parser.readResponse(false);
113       if (!response.isOk()) {
114         imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_INVALID_INITIAL_SERVER_RESPONSE);
115         throw new MessagingException(
116             MessagingException.AUTHENTICATION_FAILED_OR_SERVER_ERROR,
117             "Invalid server initial response");
118       }
119 
120       queryCapability();
121 
122       maybeDoStartTls();
123 
124       // LOGIN
125       doLogin();
126     } catch (SSLException e) {
127       LogUtils.d(TAG, "SSLException ", e);
128       imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_SSL_EXCEPTION);
129       throw new CertificateValidationException(e.getMessage(), e);
130     } catch (IOException ioe) {
131       LogUtils.d(TAG, "IOException", ioe);
132       imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_IOE_ON_OPEN);
133       throw ioe;
134     } finally {
135       destroyResponses();
136     }
137   }
138 
logout()139   void logout() {
140     try {
141       sendCommand(ImapConstants.LOGOUT, false);
142       if (!parser.readResponse(true).is(0, ImapConstants.BYE)) {
143         VvmLog.e(TAG, "Server did not respond LOGOUT with BYE");
144       }
145       if (!parser.readResponse(false).isOk()) {
146         VvmLog.e(TAG, "Server did not respond OK after LOGOUT");
147       }
148     } catch (IOException | MessagingException e) {
149       VvmLog.e(TAG, "Error while logging out:" + e);
150     }
151   }
152 
153   /**
154    * Closes the connection and releases all resources. This connection can not be used again until
155    * {@link #setStore(ImapStore)} is called.
156    */
close()157   void close() {
158     if (transport != null) {
159       logout();
160       transport.close();
161       transport = null;
162     }
163     destroyResponses();
164     parser = null;
165     imapStore = null;
166   }
167 
168   /** Attempts to convert the connection into secure connection. */
maybeDoStartTls()169   private void maybeDoStartTls() throws IOException, MessagingException {
170     // STARTTLS is required in the OMTP standard but not every implementation support it.
171     // Make sure the server does have this capability
172     if (hasCapability(ImapConstants.CAPABILITY_STARTTLS)) {
173       executeSimpleCommand(ImapConstants.STARTTLS);
174       transport.reopenTls();
175       createParser();
176       // The cached capabilities should be refreshed after TLS is established.
177       queryCapability();
178     }
179   }
180 
181   /** Logs into the IMAP server */
doLogin()182   private void doLogin() throws IOException, MessagingException, AuthenticationFailedException {
183     try {
184       if (capabilities.contains(ImapConstants.CAPABILITY_AUTH_DIGEST_MD5)) {
185         doDigestMd5Auth();
186       } else {
187         executeSimpleCommand(getLoginPhrase(), true);
188       }
189     } catch (ImapException ie) {
190       LogUtils.d(TAG, "ImapException", ie);
191       String status = ie.getStatus();
192       String statusMessage = ie.getStatusMessage();
193       String alertText = ie.getAlertText();
194 
195       if (ImapConstants.NO.equals(status)) {
196         switch (statusMessage) {
197           case ImapConstants.NO_UNKNOWN_USER:
198             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_USER);
199             break;
200           case ImapConstants.NO_UNKNOWN_CLIENT:
201             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_UNKNOWN_DEVICE);
202             break;
203           case ImapConstants.NO_INVALID_PASSWORD:
204             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_INVALID_PASSWORD);
205             break;
206           case ImapConstants.NO_MAILBOX_NOT_INITIALIZED:
207             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_MAILBOX_NOT_INITIALIZED);
208             break;
209           case ImapConstants.NO_SERVICE_IS_NOT_PROVISIONED:
210             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_PROVISIONED);
211             break;
212           case ImapConstants.NO_SERVICE_IS_NOT_ACTIVATED:
213             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_SERVICE_NOT_ACTIVATED);
214             break;
215           case ImapConstants.NO_USER_IS_BLOCKED:
216             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_AUTH_USER_IS_BLOCKED);
217             break;
218           case ImapConstants.NO_APPLICATION_ERROR:
219             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE);
220             break;
221           default:
222             imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_BAD_IMAP_CREDENTIAL);
223         }
224         throw new AuthenticationFailedException(alertText, ie);
225       }
226 
227       imapStore.getImapHelper().handleEvent(OmtpEvents.DATA_REJECTED_SERVER_RESPONSE);
228       throw new MessagingException(alertText, ie);
229     }
230   }
231 
doDigestMd5Auth()232   private void doDigestMd5Auth() throws IOException, MessagingException {
233 
234     //  Initiate the authentication.
235     //  The server will issue us a challenge, asking to run MD5 on the nonce with our password
236     //  and other data, including the cnonce we randomly generated.
237     //
238     //  C: a AUTHENTICATE DIGEST-MD5
239     //  S: (BASE64) realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth",
240     //             algorithm=md5-sess,charset=utf-8
241     List<ImapResponse> responses =
242         executeSimpleCommand(ImapConstants.AUTHENTICATE + " " + ImapConstants.AUTH_DIGEST_MD5);
243     String decodedChallenge = decodeBase64(responses.get(0).getStringOrEmpty(0).getString());
244 
245     Map<String, String> challenge = DigestMd5Utils.parseDigestMessage(decodedChallenge);
246     DigestMd5Utils.Data data = new DigestMd5Utils.Data(imapStore, transport, challenge);
247 
248     String response = data.createResponse();
249     //  Respond to the challenge. If the server accepts it, it will reply a response-auth which
250     //  is the MD5 of our password and the cnonce we've provided, to prove the server does know
251     //  the password.
252     //
253     //  C: (BASE64) charset=utf-8,username="chris",realm="elwood.innosoft.com",
254     //              nonce="OA6MG9tEQGm2hh",nc=00000001,cnonce="OA6MHXh6VqTrRk",
255     //              digest-uri="imap/elwood.innosoft.com",
256     //              response=d388dad90d4bbd760a152321f2143af7,qop=auth
257     //  S: (BASE64) rspauth=ea40f60335c427b5527b84dbabcdfffd
258 
259     responses = executeContinuationResponse(encodeBase64(response), true);
260 
261     // Verify response-auth.
262     // If failed verifyResponseAuth() will throw a MessagingException, terminating the
263     // connection
264     String decodedResponseAuth = decodeBase64(responses.get(0).getStringOrEmpty(0).getString());
265     data.verifyResponseAuth(decodedResponseAuth);
266 
267     //  Send a empty response to indicate we've accepted the response-auth
268     //
269     //  C: (empty)
270     //  S: a OK User logged in
271     executeContinuationResponse("", false);
272   }
273 
decodeBase64(String string)274   private static String decodeBase64(String string) {
275     return new String(Base64.decode(string, Base64.DEFAULT));
276   }
277 
encodeBase64(String string)278   private static String encodeBase64(String string) {
279     return Base64.encodeToString(string.getBytes(), Base64.NO_WRAP);
280   }
281 
queryCapability()282   private void queryCapability() throws IOException, MessagingException {
283     List<ImapResponse> responses = executeSimpleCommand(ImapConstants.CAPABILITY);
284     capabilities.clear();
285     Set<String> disabledCapabilities =
286         imapStore.getImapHelper().getConfig().getDisabledCapabilities();
287     for (ImapResponse response : responses) {
288       if (response.isTagged()) {
289         continue;
290       }
291       for (int i = 0; i < response.size(); i++) {
292         String capability = response.getStringOrEmpty(i).getString();
293         if (disabledCapabilities != null) {
294           if (!disabledCapabilities.contains(capability)) {
295             capabilities.add(capability);
296           }
297         } else {
298           capabilities.add(capability);
299         }
300       }
301     }
302 
303     LogUtils.d(TAG, "Capabilities: " + capabilities.toString());
304   }
305 
hasCapability(String capability)306   private boolean hasCapability(String capability) {
307     return capabilities.contains(capability);
308   }
309   /**
310    * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and set it to
311    * {@link #parser}.
312    *
313    * <p>If we already have an {@link ImapResponseParser}, we {@link #destroyResponses()} and throw
314    * it away.
315    */
createParser()316   private void createParser() {
317     destroyResponses();
318     parser = new ImapResponseParser(transport.getInputStream());
319   }
320 
destroyResponses()321   public void destroyResponses() {
322     if (parser != null) {
323       parser.destroyResponses();
324     }
325   }
326 
readResponse()327   public ImapResponse readResponse() throws IOException, MessagingException {
328     return parser.readResponse(false);
329   }
330 
executeSimpleCommand(String command)331   public List<ImapResponse> executeSimpleCommand(String command)
332       throws IOException, MessagingException {
333     return executeSimpleCommand(command, false);
334   }
335 
336   /**
337    * Send a single command to the server. The command will be preceded by an IMAP command tag and
338    * followed by \r\n (caller need not supply them). Execute a simple command at the server, a
339    * simple command being one that is sent in a single line of text
340    *
341    * @param command the command to send to the server
342    * @param sensitive whether the command should be redacted in logs (used for login)
343    * @return a list of ImapResponses
344    * @throws IOException
345    * @throws MessagingException
346    */
executeSimpleCommand(String command, boolean sensitive)347   public List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
348       throws IOException, MessagingException {
349     // TODO: It may be nice to catch IOExceptions and close the connection here.
350     // Currently, we expect callers to do that, but if they fail to we'll be in a broken state.
351     sendCommand(command, sensitive);
352     return getCommandResponses();
353   }
354 
sendCommand(String command, boolean sensitive)355   public String sendCommand(String command, boolean sensitive)
356       throws IOException, MessagingException {
357     open();
358 
359     if (transport == null) {
360       throw new IOException("Null transport");
361     }
362     String tag = Integer.toString(nextCommandTag.incrementAndGet());
363     String commandToSend = tag + " " + command;
364     transport.writeLine(commandToSend, (sensitive ? IMAP_REDACTED_LOG : command));
365     return tag;
366   }
367 
executeContinuationResponse(String response, boolean sensitive)368   List<ImapResponse> executeContinuationResponse(String response, boolean sensitive)
369       throws IOException, MessagingException {
370     transport.writeLine(response, (sensitive ? IMAP_REDACTED_LOG : response));
371     return getCommandResponses();
372   }
373 
374   /**
375    * Read and return all of the responses from the most recent command sent to the server
376    *
377    * @return a list of ImapResponses
378    * @throws IOException
379    * @throws MessagingException
380    */
getCommandResponses()381   List<ImapResponse> getCommandResponses() throws IOException, MessagingException {
382     final List<ImapResponse> responses = new ArrayList<ImapResponse>();
383     ImapResponse response;
384     do {
385       response = parser.readResponse(false);
386       responses.add(response);
387     } while (!(response.isTagged() || response.isContinuationRequest()));
388 
389     if (!(response.isOk() || response.isContinuationRequest())) {
390       final String toString = response.toString();
391       final String status = response.getStatusOrEmpty().getString();
392       final String statusMessage = response.getStatusResponseTextOrEmpty().getString();
393       final String alert = response.getAlertTextOrEmpty().getString();
394       final String responseCode = response.getResponseCodeOrEmpty().getString();
395       destroyResponses();
396       throw new ImapException(toString, status, statusMessage, alert, responseCode);
397     }
398     return responses;
399   }
400 }
401