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 */
16package com.android.voicemail.impl.mail;
17
18import android.content.Context;
19import android.net.Network;
20import android.net.TrafficStats;
21import android.support.annotation.VisibleForTesting;
22import com.android.dialer.constants.TrafficStatsTags;
23import com.android.voicemail.impl.OmtpEvents;
24import com.android.voicemail.impl.imap.ImapHelper;
25import com.android.voicemail.impl.mail.store.ImapStore;
26import com.android.voicemail.impl.mail.utils.LogUtils;
27import java.io.BufferedInputStream;
28import java.io.BufferedOutputStream;
29import java.io.IOException;
30import java.io.InputStream;
31import java.io.OutputStream;
32import java.net.InetAddress;
33import java.net.InetSocketAddress;
34import java.net.Socket;
35import java.util.ArrayList;
36import java.util.List;
37import javax.net.ssl.HostnameVerifier;
38import javax.net.ssl.HttpsURLConnection;
39import javax.net.ssl.SSLException;
40import javax.net.ssl.SSLPeerUnverifiedException;
41import javax.net.ssl.SSLSession;
42import javax.net.ssl.SSLSocket;
43
44/** Make connection and perform operations on mail server by reading and writing lines. */
45public 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 context;
56  private final ImapHelper imapHelper;
57  private final Network network;
58  private final String host;
59  private final int port;
60  private Socket socket;
61  private BufferedInputStream in;
62  private BufferedOutputStream out;
63  private final int flags;
64  private SocketCreator socketCreator;
65  private InetSocketAddress address;
66
67  public MailTransport(
68      Context context,
69      ImapHelper imapHelper,
70      Network network,
71      String address,
72      int port,
73      int flags) {
74    this.context = context;
75    this.imapHelper = imapHelper;
76    this.network = network;
77    host = address;
78    this.port = port;
79    this.flags = 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(context, imapHelper, network, host, port, flags);
89  }
90
91  public boolean canTrySslSecurity() {
92    return (flags & ImapStore.FLAG_SSL) != 0;
93  }
94
95  public boolean canTrustAllCertificates() {
96    return (flags & 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 " + host + ":" + String.valueOf(port));
105
106    List<InetSocketAddress> socketAddresses = new ArrayList<InetSocketAddress>();
107
108    if (network == null) {
109      socketAddresses.add(new InetSocketAddress(host, port));
110    } else {
111      try {
112        InetAddress[] inetAddresses = network.getAllByName(host);
113        if (inetAddresses.length == 0) {
114          throw new MessagingException(
115              MessagingException.IOERROR,
116              "Host name " + host + "cannot be resolved on designated network");
117        }
118        for (int i = 0; i < inetAddresses.length; i++) {
119          socketAddresses.add(new InetSocketAddress(inetAddresses[i], port));
120        }
121      } catch (IOException ioe) {
122        LogUtils.d(TAG, ioe.toString());
123        imapHelper.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      socket = createSocket();
131      try {
132        address = socketAddresses.remove(0);
133        socket.connect(address, 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          in = new BufferedInputStream(socket.getInputStream(), 1024);
144          out = new BufferedOutputStream(socket.getOutputStream(), 512);
145          socket.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          imapHelper.handleEvent(OmtpEvents.DATA_ALL_SOCKET_CONNECTION_FAILED);
154          throw new MessagingException(MessagingException.IOERROR, ioe.toString());
155        }
156      } finally {
157        if (!success) {
158          try {
159            socket.close();
160            socket = 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    socketCreator = creator;
179  }
180
181  protected Socket createSocket() throws MessagingException {
182    if (socketCreator != null) {
183      return socketCreator.createSocket();
184    }
185
186    if (network == 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 network.getSocketFactory().createSocket();
195    } catch (IOException ioe) {
196      LogUtils.d(TAG, ioe.toString());
197      throw new MessagingException(MessagingException.IOERROR, ioe.toString());
198    } finally {
199      TrafficStats.clearThreadStatsTag();
200    }
201  }
202
203  /** Attempts to reopen a normal connection into a TLS connection. */
204  public void reopenTls() throws MessagingException {
205    try {
206      LogUtils.d(TAG, "open: converting to TLS socket");
207      socket =
208          HttpsURLConnection.getDefaultSSLSocketFactory()
209              .createSocket(socket, address.getHostName(), address.getPort(), true);
210      // After the socket connects to an SSL server, confirm that the hostname is as
211      // expected
212      if (!canTrustAllCertificates()) {
213        verifyHostname(socket, host);
214      }
215      socket.setSoTimeout(SOCKET_READ_TIMEOUT);
216      in = new BufferedInputStream(socket.getInputStream(), 1024);
217      out = new BufferedOutputStream(socket.getOutputStream(), 512);
218
219    } catch (SSLException e) {
220      LogUtils.d(TAG, e.toString());
221      throw new CertificateValidationException(e.getMessage(), e);
222    } catch (IOException ioe) {
223      LogUtils.d(TAG, ioe.toString());
224      throw new MessagingException(MessagingException.IOERROR, ioe.toString());
225    }
226  }
227
228  /**
229   * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this service
230   * but is not in the public API.
231   *
232   * <p>Verify the hostname of the certificate used by the other end of a connected socket. It is
233   * harmless to call this method redundantly if the hostname has already been verified.
234   *
235   * <p>Wildcard certificates are allowed to verify any matching hostname, so "foo.bar.example.com"
236   * is verified if the peer has a certificate for "*.example.com".
237   *
238   * @param socket An SSL socket which has been connected to a server
239   * @param hostname The expected hostname of the remote server
240   * @throws IOException if something goes wrong handshaking with the server
241   * @throws SSLPeerUnverifiedException if the server cannot prove its identity
242   */
243  private void verifyHostname(Socket socket, String hostname) throws IOException {
244    // The code at the start of OpenSSLSocketImpl.startHandshake()
245    // ensures that the call is idempotent, so we can safely call it.
246    SSLSocket ssl = (SSLSocket) socket;
247    ssl.startHandshake();
248
249    SSLSession session = ssl.getSession();
250    if (session == null) {
251      imapHelper.handleEvent(OmtpEvents.DATA_CANNOT_ESTABLISH_SSL_SESSION);
252      throw new SSLException("Cannot verify SSL socket without session");
253    }
254    // TODO: Instead of reporting the name of the server we think we're connecting to,
255    // we should be reporting the bad name in the certificate.  Unfortunately this is buried
256    // in the verifier code and is not available in the verifier API, and extracting the
257    // CN & alts is beyond the scope of this patch.
258    if (!HOSTNAME_VERIFIER.verify(hostname, session)) {
259      imapHelper.handleEvent(OmtpEvents.DATA_SSL_INVALID_HOST_NAME);
260      throw new SSLPeerUnverifiedException(
261          "Certificate hostname not useable for server: " + session.getPeerPrincipal());
262    }
263  }
264
265  public boolean isOpen() {
266    return (in != null
267        && out != null
268        && socket != null
269        && socket.isConnected()
270        && !socket.isClosed());
271  }
272
273  /** Close the connection. MUST NOT return any exceptions - must be "best effort" and safe. */
274  public void close() {
275    try {
276      in.close();
277    } catch (Exception e) {
278      // May fail if the connection is already closed.
279    }
280    try {
281      out.close();
282    } catch (Exception e) {
283      // May fail if the connection is already closed.
284    }
285    try {
286      socket.close();
287    } catch (Exception e) {
288      // May fail if the connection is already closed.
289    }
290    in = null;
291    out = null;
292    socket = null;
293  }
294
295  public String getHost() {
296    return host;
297  }
298
299  public InputStream getInputStream() {
300    return in;
301  }
302
303  public OutputStream getOutputStream() {
304    return out;
305  }
306
307  /** Writes a single line to the server using \r\n termination. */
308  public void writeLine(String s, String sensitiveReplacement) throws IOException {
309    if (sensitiveReplacement != null) {
310      LogUtils.d(TAG, ">>> " + sensitiveReplacement);
311    } else {
312      LogUtils.d(TAG, ">>> " + s);
313    }
314
315    OutputStream out = getOutputStream();
316    out.write(s.getBytes());
317    out.write('\r');
318    out.write('\n');
319    out.flush();
320  }
321
322  /**
323   * Reads a single line from the server, using either \r\n or \n as the delimiter. The delimiter
324   * char(s) are not included in the result.
325   */
326  public String readLine(boolean loggable) throws IOException {
327    StringBuffer sb = new StringBuffer();
328    InputStream in = getInputStream();
329    int d;
330    while ((d = in.read()) != -1) {
331      if (((char) d) == '\r') {
332        continue;
333      } else if (((char) d) == '\n') {
334        break;
335      } else {
336        sb.append((char) d);
337      }
338    }
339    if (d == -1) {
340      LogUtils.d(TAG, "End of stream reached while trying to read line.");
341    }
342    String ret = sb.toString();
343    if (loggable) {
344      LogUtils.d(TAG, "<<< " + ret);
345    }
346    return ret;
347  }
348}
349