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.phone.common.mail;
17
18import android.content.Context;
19import android.net.Network;
20
21import com.android.internal.annotations.VisibleForTesting;
22import com.android.phone.common.mail.store.ImapStore;
23import com.android.phone.common.mail.utils.LogUtils;
24import com.android.phone.vvm.omtp.OmtpEvents;
25import com.android.phone.vvm.omtp.imap.ImapHelper;
26
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;
37
38import javax.net.ssl.HostnameVerifier;
39import javax.net.ssl.HttpsURLConnection;
40import javax.net.ssl.SSLException;
41import javax.net.ssl.SSLPeerUnverifiedException;
42import javax.net.ssl.SSLSession;
43import javax.net.ssl.SSLSocket;
44
45/**
46 * Make connection and perform operations on mail server by reading and writing lines.
47 */
48public class MailTransport {
49    private static final String TAG = "MailTransport";
50
51    // TODO protected eventually
52    /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000;
53    /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000;
54
55    private static final HostnameVerifier HOSTNAME_VERIFIER =
56            HttpsURLConnection.getDefaultHostnameVerifier();
57
58    private final Context mContext;
59    private final ImapHelper mImapHelper;
60    private final Network mNetwork;
61    private final String mHost;
62    private final int mPort;
63    private Socket mSocket;
64    private BufferedInputStream mIn;
65    private BufferedOutputStream mOut;
66    private final int mFlags;
67    private SocketCreator mSocketCreator;
68    private InetSocketAddress mAddress;
69
70    public MailTransport(Context context, ImapHelper imapHelper, Network network, String address,
71            int port, 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
85    public MailTransport clone() {
86        return new MailTransport(mContext, mImapHelper, mNetwork, mHost, mPort, mFlags);
87    }
88
89    public boolean canTrySslSecurity() {
90        return (mFlags & ImapStore.FLAG_SSL) != 0;
91    }
92
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
99     * an SSL connection if indicated.
100     */
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(MessagingException.IOERROR,
113                            "Host name " + mHost + "cannot be resolved on designated network");
114                }
115                for (int i = 0; i < inetAddresses.length; i++) {
116                    socketAddresses.add(new InetSocketAddress(inetAddresses[i], mPort));
117                }
118            } catch (IOException ioe) {
119                LogUtils.d(TAG, ioe.toString());
120                mImapHelper.handleEvent(OmtpEvents.DATA_CANNOT_RESOLVE_HOST_ON_NETWORK);
121                throw new MessagingException(MessagingException.IOERROR, ioe.toString());
122            }
123        }
124
125        boolean success = false;
126        while (socketAddresses.size() > 0) {
127            mSocket = createSocket();
128            try {
129                mAddress = socketAddresses.remove(0);
130                mSocket.connect(mAddress, SOCKET_CONNECT_TIMEOUT);
131
132                if (canTrySslSecurity()) {
133                    /*
134                    SSLSocket cannot be created with a connection timeout, so instead of doing a
135                    direct SSL connection, we connect with a normal connection and upgrade it into
136                    SSL
137                     */
138                    reopenTls();
139                } else {
140                    mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
141                    mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
142                    mSocket.setSoTimeout(SOCKET_READ_TIMEOUT);
143                }
144                success = true;
145                return;
146            } catch (IOException ioe) {
147                LogUtils.d(TAG, ioe.toString());
148                if (socketAddresses.size() == 0) {
149                    // Only throw an error when there are no more sockets to try.
150                    mImapHelper.handleEvent(OmtpEvents.DATA_ALL_SOCKET_CONNECTION_FAILED);
151                    throw new MessagingException(MessagingException.IOERROR, ioe.toString());
152                }
153            } finally {
154                if (!success) {
155                    try {
156                        mSocket.close();
157                        mSocket = null;
158                    } catch (IOException ioe) {
159                        throw new MessagingException(MessagingException.IOERROR, ioe.toString());
160                    }
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
171        Socket createSocket() throws MessagingException;
172    }
173
174    @VisibleForTesting
175    void setSocketCreator(SocketCreator creator) {
176        mSocketCreator = creator;
177    }
178
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    /**
199     * Attempts to reopen a normal connection into a TLS connection.
200     */
201    public void reopenTls() throws MessagingException {
202        try {
203            LogUtils.d(TAG, "open: converting to TLS socket");
204            mSocket = HttpsURLConnection.getDefaultSSLSocketFactory()
205                    .createSocket(mSocket, mAddress.getHostName(), mAddress.getPort(), true);
206            // After the socket connects to an SSL server, confirm that the hostname is as
207            // expected
208            if (!canTrustAllCertificates()) {
209                verifyHostname(mSocket, mHost);
210            }
211            mSocket.setSoTimeout(SOCKET_READ_TIMEOUT);
212            mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
213            mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
214
215        } catch (SSLException e) {
216            LogUtils.d(TAG, e.toString());
217            throw new CertificateValidationException(e.getMessage(), e);
218        } catch (IOException ioe) {
219            LogUtils.d(TAG, ioe.toString());
220            throw new MessagingException(MessagingException.IOERROR, ioe.toString());
221        }
222    }
223
224    /**
225     * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this
226     * service but is not in the public API.
227     *
228     * Verify the hostname of the certificate used by the other end of a
229     * connected socket. It is harmless to call this method redundantly if the hostname has already
230     * been verified.
231     *
232     * <p>Wildcard certificates are allowed to verify any matching hostname,
233     * so "foo.bar.example.com" is verified if the peer has a certificate
234     * 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("Certificate hostname not useable for server: "
259                    + session.getPeerPrincipal());
260        }
261    }
262
263    public boolean isOpen() {
264        return (mIn != null && mOut != null &&
265                mSocket != null && mSocket.isConnected() && !mSocket.isClosed());
266    }
267
268    /**
269     * Close the connection.  MUST NOT return any exceptions - must be "best effort" and safe.
270     */
271    public void close() {
272        try {
273            mIn.close();
274        } catch (Exception e) {
275            // May fail if the connection is already closed.
276        }
277        try {
278            mOut.close();
279        } catch (Exception e) {
280            // May fail if the connection is already closed.
281        }
282        try {
283            mSocket.close();
284        } catch (Exception e) {
285            // May fail if the connection is already closed.
286        }
287        mIn = null;
288        mOut = null;
289        mSocket = null;
290    }
291
292    public String getHost() {
293        return mHost;
294    }
295
296    public InputStream getInputStream() {
297        return mIn;
298    }
299
300    public OutputStream getOutputStream() {
301        return mOut;
302    }
303
304    /**
305     * Writes a single line to the server using \r\n termination.
306     */
307    public void writeLine(String s, String sensitiveReplacement) throws IOException {
308        if (sensitiveReplacement != null) {
309            LogUtils.d(TAG, ">>> " + sensitiveReplacement);
310        } else {
311            LogUtils.d(TAG, ">>> " + s);
312        }
313
314        OutputStream out = getOutputStream();
315        out.write(s.getBytes());
316        out.write('\r');
317        out.write('\n');
318        out.flush();
319    }
320
321    /**
322     * Reads a single line from the server, using either \r\n or \n as the delimiter.  The
323     * delimiter char(s) are not included in the result.
324     */
325    public String readLine(boolean loggable) throws IOException {
326        StringBuffer sb = new StringBuffer();
327        InputStream in = getInputStream();
328        int d;
329        while ((d = in.read()) != -1) {
330            if (((char)d) == '\r') {
331                continue;
332            } else if (((char)d) == '\n') {
333                break;
334            } else {
335                sb.append((char)d);
336            }
337        }
338        if (d == -1) {
339            LogUtils.d(TAG, "End of stream reached while trying to read line.");
340        }
341        String ret = sb.toString();
342        if (loggable) {
343            LogUtils.d(TAG, "<<< " + ret);
344        }
345        return ret;
346    }
347}
348