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