Home | History | Annotate | Download | only in mail
      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;
     17 
     18 import android.content.Context;
     19 import android.net.Network;
     20 import android.net.TrafficStats;
     21 import android.support.annotation.VisibleForTesting;
     22 import com.android.dialer.constants.TrafficStatsTags;
     23 import com.android.voicemail.impl.OmtpEvents;
     24 import com.android.voicemail.impl.imap.ImapHelper;
     25 import com.android.voicemail.impl.mail.store.ImapStore;
     26 import com.android.voicemail.impl.mail.utils.LogUtils;
     27 import java.io.BufferedInputStream;
     28 import java.io.BufferedOutputStream;
     29 import java.io.IOException;
     30 import java.io.InputStream;
     31 import java.io.OutputStream;
     32 import java.net.InetAddress;
     33 import java.net.InetSocketAddress;
     34 import java.net.Socket;
     35 import java.util.ArrayList;
     36 import java.util.List;
     37 import javax.net.ssl.HostnameVerifier;
     38 import javax.net.ssl.HttpsURLConnection;
     39 import javax.net.ssl.SSLException;
     40 import javax.net.ssl.SSLPeerUnverifiedException;
     41 import javax.net.ssl.SSLSession;
     42 import javax.net.ssl.SSLSocket;
     43 
     44 /** Make connection and perform operations on mail server by reading and writing lines. */
     45 public class MailTransport {
     46   private static final String TAG = "MailTransport";
     47 
     48   // TODO protected eventually
     49   /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000;
     50   /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000;
     51 
     52   private static final HostnameVerifier HOSTNAME_VERIFIER =
     53       HttpsURLConnection.getDefaultHostnameVerifier();
     54 
     55   private final Context mContext;
     56   private final ImapHelper mImapHelper;
     57   private final Network mNetwork;
     58   private final String mHost;
     59   private final int mPort;
     60   private Socket mSocket;
     61   private BufferedInputStream mIn;
     62   private BufferedOutputStream mOut;
     63   private final int mFlags;
     64   private SocketCreator mSocketCreator;
     65   private InetSocketAddress mAddress;
     66 
     67   public MailTransport(
     68       Context context,
     69       ImapHelper imapHelper,
     70       Network network,
     71       String address,
     72       int port,
     73       int flags) {
     74     mContext = context;
     75     mImapHelper = imapHelper;
     76     mNetwork = network;
     77     mHost = address;
     78     mPort = port;
     79     mFlags = flags;
     80   }
     81 
     82   /**
     83    * Returns a new transport, using the current transport as a model. The new transport is
     84    * configured identically, but not opened or connected in any way.
     85    */
     86   @Override
     87   public MailTransport clone() {
     88     return new MailTransport(mContext, mImapHelper, mNetwork, mHost, mPort, mFlags);
     89   }
     90 
     91   public boolean canTrySslSecurity() {
     92     return (mFlags & ImapStore.FLAG_SSL) != 0;
     93   }
     94 
     95   public boolean canTrustAllCertificates() {
     96     return (mFlags & ImapStore.FLAG_TRUST_ALL) != 0;
     97   }
     98 
     99   /**
    100    * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt an
    101    * SSL connection if indicated.
    102    */
    103   public void open() throws MessagingException {
    104     LogUtils.d(TAG, "*** IMAP open " + mHost + ":" + String.valueOf(mPort));
    105 
    106     List<InetSocketAddress> socketAddresses = new ArrayList<InetSocketAddress>();
    107 
    108     if (mNetwork == null) {
    109       socketAddresses.add(new InetSocketAddress(mHost, mPort));
    110     } else {
    111       try {
    112         InetAddress[] inetAddresses = mNetwork.getAllByName(mHost);
    113         if (inetAddresses.length == 0) {
    114           throw new MessagingException(
    115               MessagingException.IOERROR,
    116               "Host name " + mHost + "cannot be resolved on designated network");
    117         }
    118         for (int i = 0; i < inetAddresses.length; i++) {
    119           socketAddresses.add(new InetSocketAddress(inetAddresses[i], mPort));
    120         }
    121       } catch (IOException ioe) {
    122         LogUtils.d(TAG, ioe.toString());
    123         mImapHelper.handleEvent(OmtpEvents.DATA_CANNOT_RESOLVE_HOST_ON_NETWORK);
    124         throw new MessagingException(MessagingException.IOERROR, ioe.toString());
    125       }
    126     }
    127 
    128     boolean success = false;
    129     while (socketAddresses.size() > 0) {
    130       mSocket = createSocket();
    131       try {
    132         mAddress = socketAddresses.remove(0);
    133         mSocket.connect(mAddress, SOCKET_CONNECT_TIMEOUT);
    134 
    135         if (canTrySslSecurity()) {
    136           /*
    137           SSLSocket cannot be created with a connection timeout, so instead of doing a
    138           direct SSL connection, we connect with a normal connection and upgrade it into
    139           SSL
    140            */
    141           reopenTls();
    142         } else {
    143           mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
    144           mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
    145           mSocket.setSoTimeout(SOCKET_READ_TIMEOUT);
    146         }
    147         success = true;
    148         return;
    149       } catch (IOException ioe) {
    150         LogUtils.d(TAG, ioe.toString());
    151         if (socketAddresses.size() == 0) {
    152           // Only throw an error when there are no more sockets to try.
    153           mImapHelper.handleEvent(OmtpEvents.DATA_ALL_SOCKET_CONNECTION_FAILED);
    154           throw new MessagingException(MessagingException.IOERROR, ioe.toString());
    155         }
    156       } finally {
    157         if (!success) {
    158           try {
    159             mSocket.close();
    160             mSocket = null;
    161           } catch (IOException ioe) {
    162             throw new MessagingException(MessagingException.IOERROR, ioe.toString());
    163           }
    164         }
    165       }
    166     }
    167   }
    168 
    169   // For testing. We need something that can replace the behavior of "new Socket()"
    170   @VisibleForTesting
    171   interface SocketCreator {
    172 
    173     Socket createSocket() throws MessagingException;
    174   }
    175 
    176   @VisibleForTesting
    177   void setSocketCreator(SocketCreator creator) {
    178     mSocketCreator = creator;
    179   }
    180 
    181   protected Socket createSocket() throws MessagingException {
    182     if (mSocketCreator != null) {
    183       return mSocketCreator.createSocket();
    184     }
    185 
    186     if (mNetwork == null) {
    187       LogUtils.v(TAG, "createSocket: network not specified");
    188       return new Socket();
    189     }
    190 
    191     try {
    192       LogUtils.v(TAG, "createSocket: network specified");
    193       TrafficStats.setThreadStatsTag(TrafficStatsTags.VISUAL_VOICEMAIL_TAG);
    194       return mNetwork.getSocketFactory().createSocket();
    195     } catch (IOException ioe) {
    196       LogUtils.d(TAG, ioe.toString());
    197       throw new MessagingException(MessagingException.IOERROR, ioe.toString());
    198     }
    199   }
    200 
    201   /** Attempts to reopen a normal connection into a TLS connection. */
    202   public void reopenTls() throws MessagingException {
    203     try {
    204       LogUtils.d(TAG, "open: converting to TLS socket");
    205       mSocket =
    206           HttpsURLConnection.getDefaultSSLSocketFactory()
    207               .createSocket(mSocket, mAddress.getHostName(), mAddress.getPort(), true);
    208       // After the socket connects to an SSL server, confirm that the hostname is as
    209       // expected
    210       if (!canTrustAllCertificates()) {
    211         verifyHostname(mSocket, mHost);
    212       }
    213       mSocket.setSoTimeout(SOCKET_READ_TIMEOUT);
    214       mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
    215       mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
    216 
    217     } catch (SSLException e) {
    218       LogUtils.d(TAG, e.toString());
    219       throw new CertificateValidationException(e.getMessage(), e);
    220     } catch (IOException ioe) {
    221       LogUtils.d(TAG, ioe.toString());
    222       throw new MessagingException(MessagingException.IOERROR, ioe.toString());
    223     }
    224   }
    225 
    226   /**
    227    * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this service
    228    * but is not in the public API.
    229    *
    230    * <p>Verify the hostname of the certificate used by the other end of a connected socket. It is
    231    * harmless to call this method redundantly if the hostname has already been verified.
    232    *
    233    * <p>Wildcard certificates are allowed to verify any matching hostname, so "foo.bar.example.com"
    234    * is verified if the peer has a certificate for "*.example.com".
    235    *
    236    * @param socket An SSL socket which has been connected to a server
    237    * @param hostname The expected hostname of the remote server
    238    * @throws IOException if something goes wrong handshaking with the server
    239    * @throws SSLPeerUnverifiedException if the server cannot prove its identity
    240    */
    241   private void verifyHostname(Socket socket, String hostname) throws IOException {
    242     // The code at the start of OpenSSLSocketImpl.startHandshake()
    243     // ensures that the call is idempotent, so we can safely call it.
    244     SSLSocket ssl = (SSLSocket) socket;
    245     ssl.startHandshake();
    246 
    247     SSLSession session = ssl.getSession();
    248     if (session == null) {
    249       mImapHelper.handleEvent(OmtpEvents.DATA_CANNOT_ESTABLISH_SSL_SESSION);
    250       throw new SSLException("Cannot verify SSL socket without session");
    251     }
    252     // TODO: Instead of reporting the name of the server we think we're connecting to,
    253     // we should be reporting the bad name in the certificate.  Unfortunately this is buried
    254     // in the verifier code and is not available in the verifier API, and extracting the
    255     // CN & alts is beyond the scope of this patch.
    256     if (!HOSTNAME_VERIFIER.verify(hostname, session)) {
    257       mImapHelper.handleEvent(OmtpEvents.DATA_SSL_INVALID_HOST_NAME);
    258       throw new SSLPeerUnverifiedException(
    259           "Certificate hostname not useable for server: " + session.getPeerPrincipal());
    260     }
    261   }
    262 
    263   public boolean isOpen() {
    264     return (mIn != null
    265         && mOut != null
    266         && mSocket != null
    267         && mSocket.isConnected()
    268         && !mSocket.isClosed());
    269   }
    270 
    271   /** Close the connection. MUST NOT return any exceptions - must be "best effort" and safe. */
    272   public void close() {
    273     try {
    274       mIn.close();
    275     } catch (Exception e) {
    276       // May fail if the connection is already closed.
    277     }
    278     try {
    279       mOut.close();
    280     } catch (Exception e) {
    281       // May fail if the connection is already closed.
    282     }
    283     try {
    284       mSocket.close();
    285     } catch (Exception e) {
    286       // May fail if the connection is already closed.
    287     }
    288     mIn = null;
    289     mOut = null;
    290     mSocket = null;
    291   }
    292 
    293   public String getHost() {
    294     return mHost;
    295   }
    296 
    297   public InputStream getInputStream() {
    298     return mIn;
    299   }
    300 
    301   public OutputStream getOutputStream() {
    302     return mOut;
    303   }
    304 
    305   /** Writes a single line to the server using \r\n termination. */
    306   public void writeLine(String s, String sensitiveReplacement) throws IOException {
    307     if (sensitiveReplacement != null) {
    308       LogUtils.d(TAG, ">>> " + sensitiveReplacement);
    309     } else {
    310       LogUtils.d(TAG, ">>> " + s);
    311     }
    312 
    313     OutputStream out = getOutputStream();
    314     out.write(s.getBytes());
    315     out.write('\r');
    316     out.write('\n');
    317     out.flush();
    318   }
    319 
    320   /**
    321    * Reads a single line from the server, using either \r\n or \n as the delimiter. The delimiter
    322    * char(s) are not included in the result.
    323    */
    324   public String readLine(boolean loggable) throws IOException {
    325     StringBuffer sb = new StringBuffer();
    326     InputStream in = getInputStream();
    327     int d;
    328     while ((d = in.read()) != -1) {
    329       if (((char) d) == '\r') {
    330         continue;
    331       } else if (((char) d) == '\n') {
    332         break;
    333       } else {
    334         sb.append((char) d);
    335       }
    336     }
    337     if (d == -1) {
    338       LogUtils.d(TAG, "End of stream reached while trying to read line.");
    339     }
    340     String ret = sb.toString();
    341     if (loggable) {
    342       LogUtils.d(TAG, "<<< " + ret);
    343     }
    344     return ret;
    345   }
    346 }
    347