/* * 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.voicemail.impl.mail; import android.content.Context; import android.net.Network; import android.net.TrafficStats; import android.support.annotation.VisibleForTesting; import com.android.dialer.constants.TrafficStatsTags; import com.android.voicemail.impl.OmtpEvents; import com.android.voicemail.impl.imap.ImapHelper; import com.android.voicemail.impl.mail.store.ImapStore; import com.android.voicemail.impl.mail.utils.LogUtils; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.util.ArrayList; import java.util.List; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLException; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; /** Make connection and perform operations on mail server by reading and writing lines. */ public class MailTransport { private static final String TAG = "MailTransport"; // TODO protected eventually /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000; /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000; private static final HostnameVerifier HOSTNAME_VERIFIER = HttpsURLConnection.getDefaultHostnameVerifier(); private final Context context; private final ImapHelper imapHelper; private final Network network; private final String host; private final int port; private Socket socket; private BufferedInputStream in; private BufferedOutputStream out; private final int flags; private SocketCreator socketCreator; private InetSocketAddress address; public MailTransport( Context context, ImapHelper imapHelper, Network network, String address, int port, int flags) { this.context = context; this.imapHelper = imapHelper; this.network = network; host = address; this.port = port; this.flags = flags; } /** * Returns a new transport, using the current transport as a model. The new transport is * configured identically, but not opened or connected in any way. */ @Override public MailTransport clone() { return new MailTransport(context, imapHelper, network, host, port, flags); } public boolean canTrySslSecurity() { return (flags & ImapStore.FLAG_SSL) != 0; } public boolean canTrustAllCertificates() { return (flags & ImapStore.FLAG_TRUST_ALL) != 0; } /** * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt an * SSL connection if indicated. */ public void open() throws MessagingException { LogUtils.d(TAG, "*** IMAP open " + host + ":" + String.valueOf(port)); List socketAddresses = new ArrayList(); if (network == null) { socketAddresses.add(new InetSocketAddress(host, port)); } else { try { InetAddress[] inetAddresses = network.getAllByName(host); if (inetAddresses.length == 0) { throw new MessagingException( MessagingException.IOERROR, "Host name " + host + "cannot be resolved on designated network"); } for (int i = 0; i < inetAddresses.length; i++) { socketAddresses.add(new InetSocketAddress(inetAddresses[i], port)); } } catch (IOException ioe) { LogUtils.d(TAG, ioe.toString()); imapHelper.handleEvent(OmtpEvents.DATA_CANNOT_RESOLVE_HOST_ON_NETWORK); throw new MessagingException(MessagingException.IOERROR, ioe.toString()); } } boolean success = false; while (socketAddresses.size() > 0) { socket = createSocket(); try { address = socketAddresses.remove(0); socket.connect(address, SOCKET_CONNECT_TIMEOUT); if (canTrySslSecurity()) { /* SSLSocket cannot be created with a connection timeout, so instead of doing a direct SSL connection, we connect with a normal connection and upgrade it into SSL */ reopenTls(); } else { in = new BufferedInputStream(socket.getInputStream(), 1024); out = new BufferedOutputStream(socket.getOutputStream(), 512); socket.setSoTimeout(SOCKET_READ_TIMEOUT); } success = true; return; } catch (IOException ioe) { LogUtils.d(TAG, ioe.toString()); if (socketAddresses.size() == 0) { // Only throw an error when there are no more sockets to try. imapHelper.handleEvent(OmtpEvents.DATA_ALL_SOCKET_CONNECTION_FAILED); throw new MessagingException(MessagingException.IOERROR, ioe.toString()); } } finally { if (!success) { try { socket.close(); socket = null; } catch (IOException ioe) { throw new MessagingException(MessagingException.IOERROR, ioe.toString()); } } } } } // For testing. We need something that can replace the behavior of "new Socket()" @VisibleForTesting interface SocketCreator { Socket createSocket() throws MessagingException; } @VisibleForTesting void setSocketCreator(SocketCreator creator) { socketCreator = creator; } protected Socket createSocket() throws MessagingException { if (socketCreator != null) { return socketCreator.createSocket(); } if (network == null) { LogUtils.v(TAG, "createSocket: network not specified"); return new Socket(); } try { LogUtils.v(TAG, "createSocket: network specified"); TrafficStats.setThreadStatsTag(TrafficStatsTags.VISUAL_VOICEMAIL_TAG); return network.getSocketFactory().createSocket(); } catch (IOException ioe) { LogUtils.d(TAG, ioe.toString()); throw new MessagingException(MessagingException.IOERROR, ioe.toString()); } finally { TrafficStats.clearThreadStatsTag(); } } /** Attempts to reopen a normal connection into a TLS connection. */ public void reopenTls() throws MessagingException { try { LogUtils.d(TAG, "open: converting to TLS socket"); socket = HttpsURLConnection.getDefaultSSLSocketFactory() .createSocket(socket, address.getHostName(), address.getPort(), true); // After the socket connects to an SSL server, confirm that the hostname is as // expected if (!canTrustAllCertificates()) { verifyHostname(socket, host); } socket.setSoTimeout(SOCKET_READ_TIMEOUT); in = new BufferedInputStream(socket.getInputStream(), 1024); out = new BufferedOutputStream(socket.getOutputStream(), 512); } catch (SSLException e) { LogUtils.d(TAG, e.toString()); throw new CertificateValidationException(e.getMessage(), e); } catch (IOException ioe) { LogUtils.d(TAG, ioe.toString()); throw new MessagingException(MessagingException.IOERROR, ioe.toString()); } } /** * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this service * but is not in the public API. * *

Verify the hostname of the certificate used by the other end of a connected socket. It is * harmless to call this method redundantly if the hostname has already been verified. * *

Wildcard certificates are allowed to verify any matching hostname, so "foo.bar.example.com" * is verified if the peer has a certificate for "*.example.com". * * @param socket An SSL socket which has been connected to a server * @param hostname The expected hostname of the remote server * @throws IOException if something goes wrong handshaking with the server * @throws SSLPeerUnverifiedException if the server cannot prove its identity */ private void verifyHostname(Socket socket, String hostname) throws IOException { // The code at the start of OpenSSLSocketImpl.startHandshake() // ensures that the call is idempotent, so we can safely call it. SSLSocket ssl = (SSLSocket) socket; ssl.startHandshake(); SSLSession session = ssl.getSession(); if (session == null) { imapHelper.handleEvent(OmtpEvents.DATA_CANNOT_ESTABLISH_SSL_SESSION); throw new SSLException("Cannot verify SSL socket without session"); } // TODO: Instead of reporting the name of the server we think we're connecting to, // we should be reporting the bad name in the certificate. Unfortunately this is buried // in the verifier code and is not available in the verifier API, and extracting the // CN & alts is beyond the scope of this patch. if (!HOSTNAME_VERIFIER.verify(hostname, session)) { imapHelper.handleEvent(OmtpEvents.DATA_SSL_INVALID_HOST_NAME); throw new SSLPeerUnverifiedException( "Certificate hostname not useable for server: " + session.getPeerPrincipal()); } } public boolean isOpen() { return (in != null && out != null && socket != null && socket.isConnected() && !socket.isClosed()); } /** Close the connection. MUST NOT return any exceptions - must be "best effort" and safe. */ public void close() { try { in.close(); } catch (Exception e) { // May fail if the connection is already closed. } try { out.close(); } catch (Exception e) { // May fail if the connection is already closed. } try { socket.close(); } catch (Exception e) { // May fail if the connection is already closed. } in = null; out = null; socket = null; } public String getHost() { return host; } public InputStream getInputStream() { return in; } public OutputStream getOutputStream() { return out; } /** Writes a single line to the server using \r\n termination. */ public void writeLine(String s, String sensitiveReplacement) throws IOException { if (sensitiveReplacement != null) { LogUtils.d(TAG, ">>> " + sensitiveReplacement); } else { LogUtils.d(TAG, ">>> " + s); } OutputStream out = getOutputStream(); out.write(s.getBytes()); out.write('\r'); out.write('\n'); out.flush(); } /** * Reads a single line from the server, using either \r\n or \n as the delimiter. The delimiter * char(s) are not included in the result. */ public String readLine(boolean loggable) throws IOException { StringBuffer sb = new StringBuffer(); InputStream in = getInputStream(); int d; while ((d = in.read()) != -1) { if (((char) d) == '\r') { continue; } else if (((char) d) == '\n') { break; } else { sb.append((char) d); } } if (d == -1) { LogUtils.d(TAG, "End of stream reached while trying to read line."); } String ret = sb.toString(); if (loggable) { LogUtils.d(TAG, "<<< " + ret); } return ret; } }