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