package com.fsck.k9.mail.store.imap; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.ConnectException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; import java.net.SocketException; import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.Security; import java.security.cert.CertificateException; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import android.net.ConnectivityManager; import android.net.NetworkInfo; import com.fsck.k9.mail.Authentication; import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.K9MailLib; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.NetworkType; import com.fsck.k9.mail.filter.Base64; import com.fsck.k9.mail.filter.PeekableInputStream; import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.jcraft.jzlib.JZlib; import com.jcraft.jzlib.ZOutputStream; import javax.net.ssl.SSLException; import org.apache.commons.io.IOUtils; import timber.log.Timber; import static com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED; import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP; import static com.fsck.k9.mail.store.RemoteStore.SOCKET_CONNECT_TIMEOUT; import static com.fsck.k9.mail.store.RemoteStore.SOCKET_READ_TIMEOUT; import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; /** * A cacheable class that stores the details for a single IMAP connection. */ class ImapConnection { private static final int BUFFER_SIZE = 1024; private final ConnectivityManager connectivityManager; private final OAuth2TokenProvider oauthTokenProvider; private final TrustedSocketFactory socketFactory; private final int socketConnectTimeout; private final int socketReadTimeout; private Socket socket; private PeekableInputStream inputStream; private OutputStream outputStream; private ImapResponseParser responseParser; private int nextCommandTag; private Set<String> capabilities = new HashSet<String>(); private ImapSettings settings; private Exception stacktraceForClose; private boolean open = false; private boolean retryXoauth2WithNewToken = true; public ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider) { this.settings = settings; this.socketFactory = socketFactory; this.connectivityManager = connectivityManager; this.oauthTokenProvider = oauthTokenProvider; this.socketConnectTimeout = SOCKET_CONNECT_TIMEOUT; this.socketReadTimeout = SOCKET_READ_TIMEOUT; } ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider, int socketConnectTimeout, int socketReadTimeout) { this.settings = settings; this.socketFactory = socketFactory; this.connectivityManager = connectivityManager; this.oauthTokenProvider = oauthTokenProvider; this.socketConnectTimeout = socketConnectTimeout; this.socketReadTimeout = socketReadTimeout; } public void open() throws IOException, MessagingException { if (open) { return; } else if (stacktraceForClose != null) { throw new IllegalStateException("open() called after close(). " + "Check wrapped exception to see where close() was called.", stacktraceForClose); } open = true; boolean authSuccess = false; nextCommandTag = 1; adjustDNSCacheTTL(); try { socket = connect(); configureSocket(); setUpStreamsAndParserFromSocket(); readInitialResponse(); requestCapabilitiesIfNecessary(); upgradeToTlsIfNecessary(); authenticate(); authSuccess = true; enableCompressionIfRequested(); retrievePathPrefixIfNecessary(); retrievePathDelimiterIfNecessary(); } catch (SSLException e) { handleSslException(e); } catch (ConnectException e) { handleConnectException(e); } catch (GeneralSecurityException e) { throw new MessagingException("Unable to open connection to IMAP server due to security error.", e); } finally { if (!authSuccess) { Timber.e("Failed to login, closing connection for %s", getLogId()); close(); } } } private void handleSslException(SSLException e) throws CertificateValidationException, SSLException { if (e.getCause() instanceof CertificateException) { throw new CertificateValidationException(e.getMessage(), e); } else { throw e; } } private void handleConnectException(ConnectException e) throws ConnectException { String message = e.getMessage(); String[] tokens = message.split("-"); if (tokens.length > 1 && tokens[1] != null) { Timber.e(e, "Stripping host/port from ConnectionException for %s", getLogId()); throw new ConnectException(tokens[1].trim()); } else { throw e; } } public boolean isConnected() { return inputStream != null && outputStream != null && socket != null && socket.isConnected() && !socket.isClosed(); } private void adjustDNSCacheTTL() { try { Security.setProperty("networkaddress.cache.ttl", "0"); } catch (Exception e) { Timber.w(e, "Could not set DNS ttl to 0 for %s", getLogId()); } try { Security.setProperty("networkaddress.cache.negative.ttl", "0"); } catch (Exception e) { Timber.w(e, "Could not set DNS negative ttl to 0 for %s", getLogId()); } } private Socket connect() throws GeneralSecurityException, MessagingException, IOException { Exception connectException = null; InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost()); for (InetAddress address : inetAddresses) { try { return connectToAddress(address); } catch (IOException e) { Timber.w(e, "Could not connect to %s", address); connectException = e; } } throw new MessagingException("Cannot connect to host", connectException); } private Socket connectToAddress(InetAddress address) throws NoSuchAlgorithmException, KeyManagementException, MessagingException, IOException { String host = settings.getHost(); int port = settings.getPort(); String clientCertificateAlias = settings.getClientCertificateAlias(); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { Timber.d("Connecting to %s as %s", host, address); } SocketAddress socketAddress = new InetSocketAddress(address, port); Socket socket; if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) { socket = socketFactory.createSocket(null, host, port, clientCertificateAlias); } else { socket = new Socket(); } socket.connect(socketAddress, socketConnectTimeout); return socket; } private void configureSocket() throws SocketException { socket.setSoTimeout(socketReadTimeout); } private void setUpStreamsAndParserFromSocket() throws IOException { setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream()); } private void setUpStreamsAndParser(InputStream input, OutputStream output) { inputStream = new PeekableInputStream(new BufferedInputStream(input, BUFFER_SIZE)); responseParser = new ImapResponseParser(inputStream); outputStream = new BufferedOutputStream(output, BUFFER_SIZE); } private void readInitialResponse() throws IOException { ImapResponse initialResponse = responseParser.readResponse(); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { Timber.v("%s <<< %s", getLogId(), initialResponse); } extractCapabilities(Collections.singletonList(initialResponse)); } private List<ImapResponse> extractCapabilities(List<ImapResponse> responses) { CapabilityResponse capabilityResponse = CapabilityResponse.parse(responses); if (capabilityResponse != null) { Set<String> receivedCapabilities = capabilityResponse.getCapabilities(); if (K9MailLib.isDebug()) { Timber.d("Saving %s capabilities for %s", receivedCapabilities, getLogId()); } capabilities = receivedCapabilities; } return responses; } private void requestCapabilitiesIfNecessary() throws IOException, MessagingException { if (!capabilities.isEmpty()) { return; } if (K9MailLib.isDebug()) { Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", getLogId()); } requestCapabilities(); } private void requestCapabilities() throws IOException, MessagingException { List<ImapResponse> responses = extractCapabilities(executeSimpleCommand(Commands.CAPABILITY)); if (responses.size() != 2) { throw new MessagingException("Invalid CAPABILITY response received"); } } private void upgradeToTlsIfNecessary() throws IOException, MessagingException, GeneralSecurityException { if (settings.getConnectionSecurity() == STARTTLS_REQUIRED) { upgradeToTls(); } } private void upgradeToTls() throws IOException, MessagingException, GeneralSecurityException { if (!hasCapability(Capabilities.STARTTLS)) { /* * This exception triggers a "Certificate error" * notification that takes the user to the incoming * server settings for review. This might be needed if * the account was configured with an obsolete * "STARTTLS (if available)" setting. */ throw new CertificateValidationException("STARTTLS connection security not available"); } startTLS(); } private void startTLS() throws IOException, MessagingException, GeneralSecurityException { executeSimpleCommand(Commands.STARTTLS); String host = settings.getHost(); int port = settings.getPort(); String clientCertificateAlias = settings.getClientCertificateAlias(); socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias); configureSocket(); setUpStreamsAndParserFromSocket(); // Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command if (K9MailLib.isDebug()) { Timber.i("Updating capabilities after STARTTLS for %s", getLogId()); } requestCapabilities(); } @SuppressWarnings("EnumSwitchStatementWhichMissesCases") private void authenticate() throws MessagingException, IOException { switch (settings.getAuthType()) { case XOAUTH2: if (oauthTokenProvider == null) { throw new MessagingException("No OAuthToken Provider available."); } else if (hasCapability(Capabilities.AUTH_XOAUTH2) && hasCapability(Capabilities.SASL_IR)) { authXoauth2withSASLIR(); } else { throw new MessagingException("Server doesn't support SASL XOAUTH2."); } break; case CRAM_MD5: { if (hasCapability(Capabilities.AUTH_CRAM_MD5)) { authCramMD5(); } else { throw new MessagingException("Server doesn't support encrypted passwords using CRAM-MD5."); } break; } case PLAIN: { if (hasCapability(Capabilities.AUTH_PLAIN)) { saslAuthPlainWithLoginFallback(); } else if (!hasCapability(Capabilities.LOGINDISABLED)) { login(); } else { throw new MessagingException("Server doesn't support unencrypted passwords using AUTH=PLAIN " + "and LOGIN is disabled."); } break; } case EXTERNAL: { if (hasCapability(Capabilities.AUTH_EXTERNAL)) { saslAuthExternal(); } else { // Provide notification to user of a problem authenticating using client certificates throw new CertificateValidationException(CertificateValidationException.Reason.MissingCapability); } break; } default: { throw new MessagingException("Unhandled authentication method found in the server settings (bug)."); } } } private void authXoauth2withSASLIR() throws IOException, MessagingException { retryXoauth2WithNewToken = true; try { attemptXOAuth2(); } catch (NegativeImapResponseException e) { oauthTokenProvider.invalidateToken(settings.getUsername()); if (!retryXoauth2WithNewToken) { handlePermanentXoauth2Failure(e); } else { handleTemporaryXoauth2Failure(e); } } } private void handlePermanentXoauth2Failure(NegativeImapResponseException e) throws AuthenticationFailedException { Timber.v(e, "Permanent failure during XOAUTH2"); throw new AuthenticationFailedException(e.getMessage(), e); } private void handleTemporaryXoauth2Failure(NegativeImapResponseException e) throws IOException, MessagingException { //We got a response indicating a retry might suceed after token refresh //We could avoid this if we had a reasonable chance of knowing //if a token was invalid before use (e.g. due to expiry). But we don't //This is the intended behaviour per AccountManager Timber.v(e, "Temporary failure - retrying with new token"); try { attemptXOAuth2(); } catch (NegativeImapResponseException e2) { //Okay, we failed on a new token. //Invalidate the token anyway but assume it's permanent. Timber.v(e, "Authentication exception for new token, permanent error assumed"); oauthTokenProvider.invalidateToken(settings.getUsername()); handlePermanentXoauth2Failure(e2); } } private void attemptXOAuth2() throws MessagingException, IOException { String token = oauthTokenProvider.getToken(settings.getUsername(), OAuth2TokenProvider.OAUTH2_TIMEOUT); String authString = Authentication.computeXoauth(settings.getUsername(), token); String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2, authString, true); List<ImapResponse> responses = responseParser.readStatusResponse(tag, Commands.AUTHENTICATE_XOAUTH2, getLogId(), new UntaggedHandler() { @Override public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException { handleXOAuthUntaggedResponse(response); } }); extractCapabilities(responses); } private void handleXOAuthUntaggedResponse(ImapResponse response) throws IOException { if (response.isString(0)) { retryXoauth2WithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.getHost()); } if (response.isContinuationRequested()) { outputStream.write("\r\n".getBytes()); outputStream.flush(); } } private void authCramMD5() throws MessagingException, IOException { String command = Commands.AUTHENTICATE_CRAM_MD5; String tag = sendCommand(command, false); ImapResponse response = readContinuationResponse(tag); if (response.size() != 1 || !(response.get(0) instanceof String)) { throw new MessagingException("Invalid Cram-MD5 nonce received"); } byte[] b64Nonce = response.getString(0).getBytes(); byte[] b64CRAM = Authentication.computeCramMd5Bytes(settings.getUsername(), settings.getPassword(), b64Nonce); outputStream.write(b64CRAM); outputStream.write('\r'); outputStream.write('\n'); outputStream.flush(); try { extractCapabilities(responseParser.readStatusResponse(tag, command, getLogId(), null)); } catch (NegativeImapResponseException e) { throw new AuthenticationFailedException(e.getMessage()); } } private void saslAuthPlainWithLoginFallback() throws IOException, MessagingException { try { saslAuthPlain(); } catch (AuthenticationFailedException e) { if (!isConnected()) { throw e; } login(); } } private void saslAuthPlain() throws IOException, MessagingException { String command = Commands.AUTHENTICATE_PLAIN; String tag = sendCommand(command, false); readContinuationResponse(tag); String credentials = "\000" + settings.getUsername() + "\000" + settings.getPassword(); byte[] encodedCredentials = Base64.encodeBase64(credentials.getBytes()); outputStream.write(encodedCredentials); outputStream.write('\r'); outputStream.write('\n'); outputStream.flush(); try { extractCapabilities(responseParser.readStatusResponse(tag, command, getLogId(), null)); } catch (NegativeImapResponseException e) { if (e.wasByeResponseReceived()) { close(); } throw new AuthenticationFailedException(e.getMessage()); } } private void login() throws IOException, MessagingException { /* * Use quoted strings which permit spaces and quotes. (Using IMAP * string literals would be better, but some servers are broken * and don't parse them correctly.) */ // escape double-quotes and backslash characters with a backslash Pattern p = Pattern.compile("[\\\\\"]"); String replacement = "\\\\$0"; String username = p.matcher(settings.getUsername()).replaceAll(replacement); String password = p.matcher(settings.getPassword()).replaceAll(replacement); try { String command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", username, password); extractCapabilities(executeSimpleCommand(command, true)); } catch (NegativeImapResponseException e) { throw new AuthenticationFailedException(e.getMessage()); } } private void saslAuthExternal() throws IOException, MessagingException { try { String command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.getUsername()); extractCapabilities(executeSimpleCommand(command, false)); } catch (NegativeImapResponseException e) { /* * Provide notification to the user of a problem authenticating * using client certificates. We don't use an * AuthenticationFailedException because that would trigger a * "Username or password incorrect" notification in * AccountSetupCheckSettings. */ throw new CertificateValidationException(e.getMessage()); } } private void enableCompressionIfRequested() throws IOException, MessagingException { if (hasCapability(Capabilities.COMPRESS_DEFLATE) && shouldEnableCompression()) { enableCompression(); } } private boolean shouldEnableCompression() { boolean useCompression = true; NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); if (networkInfo != null) { int type = networkInfo.getType(); if (K9MailLib.isDebug()) { Timber.d("On network type %s", type); } NetworkType networkType = NetworkType.fromConnectivityManagerType(type); useCompression = settings.useCompression(networkType); } if (K9MailLib.isDebug()) { Timber.d("useCompression: %b", useCompression); } return useCompression; } private void enableCompression() throws IOException, MessagingException { try { executeSimpleCommand(Commands.COMPRESS_DEFLATE); } catch (NegativeImapResponseException e) { Timber.d(e, "Unable to negotiate compression: "); return; } try { InflaterInputStream input = new InflaterInputStream(socket.getInputStream(), new Inflater(true)); ZOutputStream output = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true); output.setFlushMode(JZlib.Z_PARTIAL_FLUSH); setUpStreamsAndParser(input, output); if (K9MailLib.isDebug()) { Timber.i("Compression enabled for %s", getLogId()); } } catch (IOException e) { close(); Timber.e(e, "Error enabling compression"); } } private void retrievePathPrefixIfNecessary() throws IOException, MessagingException { if (settings.getPathPrefix() != null) { return; } if (hasCapability(Capabilities.NAMESPACE)) { if (K9MailLib.isDebug()) { Timber.i("pathPrefix is unset and server has NAMESPACE capability"); } handleNamespace(); } else { if (K9MailLib.isDebug()) { Timber.i("pathPrefix is unset but server does not have NAMESPACE capability"); } settings.setPathPrefix(""); } } private void handleNamespace() throws IOException, MessagingException { List<ImapResponse> responses = executeSimpleCommand(Commands.NAMESPACE); NamespaceResponse namespaceResponse = NamespaceResponse.parse(responses); if (namespaceResponse != null) { String prefix = namespaceResponse.getPrefix(); String hierarchyDelimiter = namespaceResponse.getHierarchyDelimiter(); settings.setPathPrefix(prefix); settings.setPathDelimiter(hierarchyDelimiter); settings.setCombinedPrefix(null); if (K9MailLib.isDebug()) { Timber.d("Got path '%s' and separator '%s'", prefix, hierarchyDelimiter); } } } private void retrievePathDelimiterIfNecessary() throws IOException, MessagingException { if (settings.getPathDelimiter() == null) { retrievePathDelimiter(); } } private void retrievePathDelimiter() throws IOException, MessagingException { List<ImapResponse> listResponses; try { listResponses = executeSimpleCommand(Commands.LIST + " \"\" \"\""); } catch (NegativeImapResponseException e) { Timber.d(e, "Error getting path delimiter using LIST command"); return; } for (ImapResponse response : listResponses) { if (isListResponse(response)) { String hierarchyDelimiter = response.getString(2); settings.setPathDelimiter(hierarchyDelimiter); settings.setCombinedPrefix(null); if (K9MailLib.isDebug()) { Timber.d("Got path delimiter '%s' for %s", settings.getPathDelimiter(), getLogId()); } break; } } } private boolean isListResponse(ImapResponse response) { boolean responseTooShort = response.size() < 4; if (responseTooShort) { return false; } boolean isListResponse = equalsIgnoreCase(response.get(0), Responses.LIST); boolean hierarchyDelimiterValid = response.get(2) instanceof String; return isListResponse && hierarchyDelimiterValid; } protected boolean hasCapability(String capability) { return capabilities.contains(capability.toUpperCase(Locale.US)); } protected boolean isIdleCapable() { if (K9MailLib.isDebug()) { Timber.v("Connection %s has %d capabilities", getLogId(), capabilities.size()); } return capabilities.contains(Capabilities.IDLE); } public void close() { open = false; stacktraceForClose = new Exception(); IOUtils.closeQuietly(inputStream); IOUtils.closeQuietly(outputStream); IOUtils.closeQuietly(socket); inputStream = null; outputStream = null; socket = null; } public OutputStream getOutputStream() { return outputStream; } protected String getLogId() { return "conn" + hashCode(); } public List<ImapResponse> executeSimpleCommand(String command) throws IOException, MessagingException { return executeSimpleCommand(command, false); } public List<ImapResponse> executeSimpleCommand(String command, boolean sensitive) throws IOException, MessagingException { String commandToLog = command; if (sensitive && !K9MailLib.isDebugSensitive()) { commandToLog = "*sensitive*"; } String tag = sendCommand(command, sensitive); try { return responseParser.readStatusResponse(tag, commandToLog, getLogId(), null); } catch (IOException e) { close(); throw e; } } public List<ImapResponse> readStatusResponse(String tag, String commandToLog, UntaggedHandler untaggedHandler) throws IOException, NegativeImapResponseException { return responseParser.readStatusResponse(tag, commandToLog, getLogId(), untaggedHandler); } public String sendSaslIrCommand(String command, String initialClientResponse, boolean sensitive) throws IOException, MessagingException { try { open(); String tag = Integer.toString(nextCommandTag++); String commandToSend = tag + " " + command + " " + initialClientResponse + "\r\n"; outputStream.write(commandToSend.getBytes()); outputStream.flush(); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { if (sensitive && !K9MailLib.isDebugSensitive()) { Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId()); } else { Timber.v("%s>>> %s %s %s", getLogId(), tag, command, initialClientResponse); } } return tag; } catch (IOException | MessagingException e) { close(); throw e; } } public String sendCommand(String command, boolean sensitive) throws MessagingException, IOException { try { open(); String tag = Integer.toString(nextCommandTag++); String commandToSend = tag + " " + command + "\r\n"; outputStream.write(commandToSend.getBytes()); outputStream.flush(); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { if (sensitive && !K9MailLib.isDebugSensitive()) { Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId()); } else { Timber.v("%s>>> %s %s", getLogId(), tag, command); } } return tag; } catch (IOException | MessagingException e) { close(); throw e; } } public void sendContinuation(String continuation) throws IOException { outputStream.write(continuation.getBytes()); outputStream.write('\r'); outputStream.write('\n'); outputStream.flush(); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { Timber.v("%s>>> %s", getLogId(), continuation); } } public ImapResponse readResponse() throws IOException, MessagingException { return readResponse(null); } public ImapResponse readResponse(ImapResponseCallback callback) throws IOException { try { ImapResponse response = responseParser.readResponse(callback); if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { Timber.v("%s<<<%s", getLogId(), response); } return response; } catch (IOException e) { close(); throw e; } } protected void setReadTimeout(int millis) throws SocketException { Socket sock = socket; if (sock != null) { sock.setSoTimeout(millis); } } private ImapResponse readContinuationResponse(String tag) throws IOException, MessagingException { ImapResponse response; do { response = readResponse(); String responseTag = response.getTag(); if (responseTag != null) { if (responseTag.equalsIgnoreCase(tag)) { throw new MessagingException("Command continuation aborted: " + response); } else { Timber.w("After sending tag %s, got tag response from previous command %s for %s", tag, response, getLogId()); } } } while (!response.isContinuationRequested()); return response; } }