/* * Copyright (c) 2013, Psiphon Inc. * All rights reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ package ca.psiphon.ploggy; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.util.Arrays; import java.util.List; import javax.net.ssl.SSLContext; import android.util.Pair; import ch.boye.httpclientandroidlib.HttpEntity; import ch.boye.httpclientandroidlib.HttpHost; import ch.boye.httpclientandroidlib.HttpResponse; import ch.boye.httpclientandroidlib.HttpStatus; import ch.boye.httpclientandroidlib.client.methods.HttpGet; import ch.boye.httpclientandroidlib.client.methods.HttpPost; import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; import ch.boye.httpclientandroidlib.client.utils.URIBuilder; import ch.boye.httpclientandroidlib.conn.ClientConnectionManager; import ch.boye.httpclientandroidlib.conn.ClientConnectionOperator; import ch.boye.httpclientandroidlib.conn.OperatedClientConnection; import ch.boye.httpclientandroidlib.conn.scheme.Scheme; import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry; import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory; import ch.boye.httpclientandroidlib.entity.InputStreamEntity; import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; import ch.boye.httpclientandroidlib.impl.conn.DefaultClientConnectionOperator; import ch.boye.httpclientandroidlib.impl.conn.PoolingClientConnectionManager; import ch.boye.httpclientandroidlib.params.BasicHttpParams; import ch.boye.httpclientandroidlib.params.HttpConnectionParams; import ch.boye.httpclientandroidlib.params.HttpParams; import ch.boye.httpclientandroidlib.protocol.HttpContext; /** * Client-side for Ploggy friend-to-friend requests. * * Implements HTTP requests through Tor with TLS configured with TransportSecurity specs and mutual * authentication. */ public class WebClient { private static final String LOG_TAG = "Web Client"; public static final int UNTUNNELED_REQUEST = -1; private static final String LOCAL_SOCKS_PROXY_PORT_PARAM_NAME = "localSocksProxyPort"; private static final int CONNECT_TIMEOUT_MILLISECONDS = 60000; private static final int READ_TIMEOUT_MILLISECONDS = 60000; // TODO: fluent interface for makeRequest public static String makeGetRequest( X509.KeyMaterial x509KeyMaterial, String peerCertificate, int localSocksProxyPort, String hostname, int port, String requestPath) throws Utils.ApplicationError { ByteArrayOutputStream responseBodyStream = new ByteArrayOutputStream(); makeRequest( x509KeyMaterial, peerCertificate, localSocksProxyPort, hostname, port, requestPath, null, // requestParameters null, // requestBodyMimeType -1, // requestBodyLength null, // requestBodyStream null, // rangeHeader responseBodyStream); try { return new String(responseBodyStream.toByteArray(), "UTF-8"); } catch (UnsupportedEncodingException e) { throw new Utils.ApplicationError(LOG_TAG, e); } } public static String makeGetRequest( X509.KeyMaterial x509KeyMaterial, String peerCertificate, int localSocksProxyPort, String hostname, int port, String requestPath, List<Pair<String,String>> requestParameters) throws Utils.ApplicationError { ByteArrayOutputStream responseBodyStream = new ByteArrayOutputStream(); makeRequest( x509KeyMaterial, peerCertificate, localSocksProxyPort, hostname, port, requestPath, requestParameters, // requestParameters null, // requestBodyMimeType -1, // requestBodyLength null, // requestBodyStream null, // rangeHeader responseBodyStream); try { return new String(responseBodyStream.toByteArray(), "UTF-8"); } catch (UnsupportedEncodingException e) { throw new Utils.ApplicationError(LOG_TAG, e); } } public static void makeGetRequest( X509.KeyMaterial x509KeyMaterial, String peerCertificate, int localSocksProxyPort, String hostname, int port, String requestPath, List<Pair<String,String>> requestParameters, Pair<Long, Long> rangeHeader, OutputStream responseBodyStream) throws Utils.ApplicationError { makeRequest( x509KeyMaterial, peerCertificate, localSocksProxyPort, hostname, port, requestPath, requestParameters, null, // requestBodyMimeType -1, // requestBodyLength null, // requestBodyStream rangeHeader, responseBodyStream); } public static void makeJsonPostRequest( X509.KeyMaterial x509KeyMaterial, String peerCertificate, int localSocksProxyPort, String hostname, int port, String requestPath, String requestBody) throws Utils.ApplicationError { byte[] body; try { body = requestBody.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { throw new Utils.ApplicationError(LOG_TAG, e); } makeRequest( x509KeyMaterial, peerCertificate, localSocksProxyPort, hostname, port, requestPath, null, // requestParameters "application/json", body.length, new ByteArrayInputStream(body), null, // rangeHeader null); // responseBodyStream } private static void makeRequest( X509.KeyMaterial x509KeyMaterial, String peerCertificate, int localSocksProxyPort, String hostname, int port, String requestPath, List<Pair<String,String>> requestParameters, String requestBodyMimeType, long requestBodyLength, InputStream requestBodyStream, Pair<Long, Long> rangeHeader, OutputStream responseBodyStream) throws Utils.ApplicationError { HttpRequestBase request = null; ClientConnectionManager connectionManager = null; try { URIBuilder uriBuilder = new URIBuilder() .setScheme(Protocol.WEB_SERVER_PROTOCOL) .setHost(hostname) .setPort(port) .setPath(requestPath); if (requestParameters != null) { for (Pair<String,String> requestParameter : requestParameters) { uriBuilder.addParameter(requestParameter.first, requestParameter.second); } } URI uri = uriBuilder.build(); SSLContext sslContext = TransportSecurity.getSSLContext(x509KeyMaterial, Arrays.asList(peerCertificate)); SSLSocketFactory sslSocketFactory = TransportSecurity.getClientSSLSocketFactory(sslContext); // TODO: keep a persistent PoolingClientConnectionManager across makeRequest calls for connection reuse? SchemeRegistry registry = new SchemeRegistry(); registry.register(new Scheme(Protocol.WEB_SERVER_PROTOCOL, Protocol.WEB_SERVER_VIRTUAL_PORT, sslSocketFactory)); if (localSocksProxyPort == UNTUNNELED_REQUEST) { connectionManager = new PoolingClientConnectionManager(registry); } else { connectionManager = new SocksProxyPoolingClientConnectionManager(registry); } HttpParams params = new BasicHttpParams(); HttpConnectionParams.setConnectionTimeout(params, CONNECT_TIMEOUT_MILLISECONDS); HttpConnectionParams.setSoTimeout(params, READ_TIMEOUT_MILLISECONDS); params.setIntParameter(LOCAL_SOCKS_PROXY_PORT_PARAM_NAME, localSocksProxyPort); DefaultHttpClient client = new DefaultHttpClient(connectionManager, params); if (requestBodyStream == null) { request = new HttpGet(uri); } else { HttpPost postRequest = new HttpPost(uri); InputStreamEntity entity = new InputStreamEntity(requestBodyStream, requestBodyLength); entity.setContentType(requestBodyMimeType); postRequest.setEntity(entity); request = postRequest; } if (rangeHeader != null) { String value = "bytes=" + Long.toString(rangeHeader.first) + "-"; if (rangeHeader.second != -1) { value = value + Long.toString(rangeHeader.second); } request.addHeader("Range", value); } HttpResponse response = client.execute(request); int statusCode = response.getStatusLine().getStatusCode(); if (statusCode != HttpStatus.SC_OK) { throw new Utils.ApplicationError(LOG_TAG, String.format("HTTP request failed with %d", statusCode)); } HttpEntity responseEntity = response.getEntity(); if (responseBodyStream != null) { Utils.copyStream(responseEntity.getContent(), responseBodyStream); } else { // Even if the caller doesn't want the content, we need to consume the bytes // (particularly if leaving the socket up in a keep-alive state). Utils.discardStream(responseEntity.getContent()); } } catch (URISyntaxException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (UnsupportedOperationException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (IllegalStateException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (IllegalArgumentException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (NullPointerException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (IOException e) { throw new Utils.ApplicationError(LOG_TAG, e); } finally { if (request != null && !request.isAborted()) { request.abort(); } if (connectionManager != null) { connectionManager.shutdown(); } } } private static class SocksProxyPoolingClientConnectionManager extends PoolingClientConnectionManager { public SocksProxyPoolingClientConnectionManager(SchemeRegistry registry) { super(registry); } @Override protected ClientConnectionOperator createConnectionOperator(SchemeRegistry registry) { return new SocksProxyClientConnectionOperator(registry); } } private static class SocksProxyClientConnectionOperator extends DefaultClientConnectionOperator { public SocksProxyClientConnectionOperator(SchemeRegistry registry) { super(registry); } // Derived from the original DefaultClientConnectionOperator.java in Apache HttpClient 4.2 @Override public void openConnection( final OperatedClientConnection conn, final HttpHost target, final InetAddress local, final HttpContext context, final HttpParams params) throws IOException { Socket socket = null; Socket sslSocket = null; try { if (conn == null || target == null || params == null) { throw new IllegalArgumentException("Required argument may not be null"); } if (conn.isOpen()) { throw new IllegalStateException("Connection must not be open"); } Scheme scheme = schemeRegistry.getScheme(target.getSchemeName()); SSLSocketFactory sslSocketFactory = (SSLSocketFactory)scheme.getSchemeSocketFactory(); int port = scheme.resolvePort(target.getPort()); String host = target.getHostName(); // Perform explicit SOCKS4a connection request. SOCKS4a supports remote host name resolution // (i.e., Tor resolves the hostname, which may be an onion address). // The Android (Apache Harmony) Socket class appears to support only SOCKS4 and throws an // exception on an address created using INetAddress.createUnresolved() -- so the typical // technique for using Java SOCKS4a/5 doesn't appear to work on Android: // https://android.googlesource.com/platform/libcore/+/master/luni/src/main/java/java/net/PlainSocketImpl.java // See also: http://www.mit.edu/~foley/TinFoil/src/tinfoil/TorLib.java, for a similar implementation // From http://en.wikipedia.org/wiki/SOCKS#SOCKS4a: // // field 1: SOCKS version number, 1 byte, must be 0x04 for this version // field 2: command code, 1 byte: // 0x01 = establish a TCP/IP stream connection // 0x02 = establish a TCP/IP port binding // field 3: network byte order port number, 2 bytes // field 4: deliberate invalid IP address, 4 bytes, first three must be 0x00 and the last one must not be 0x00 // field 5: the user ID string, variable length, terminated with a null (0x00) // field 6: the domain name of the host we want to contact, variable length, terminated with a null (0x00) int localSocksProxyPort = params.getIntParameter(LOCAL_SOCKS_PROXY_PORT_PARAM_NAME, -1); socket = new Socket(); conn.opening(socket, target); socket.setSoTimeout(READ_TIMEOUT_MILLISECONDS); socket.connect(new InetSocketAddress("127.0.0.1", localSocksProxyPort), CONNECT_TIMEOUT_MILLISECONDS); DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream()); outputStream.write((byte)0x04); outputStream.write((byte)0x01); outputStream.writeShort((short)port); outputStream.writeInt(0x01); outputStream.write((byte)0x00); outputStream.write(host.getBytes()); outputStream.write((byte)0x00); DataInputStream inputStream = new DataInputStream(socket.getInputStream()); if (inputStream.readByte() != (byte)0x00 || inputStream.readByte() != (byte)0x5a) { throw new IOException("SOCKS4a connect failed"); } inputStream.readShort(); inputStream.readInt(); sslSocket = sslSocketFactory.createLayeredSocket(socket, host, port, params); conn.opening(sslSocket, target); sslSocket.setSoTimeout(READ_TIMEOUT_MILLISECONDS); prepareSocket(sslSocket, context, params); conn.openCompleted(sslSocketFactory.isSecure(sslSocket), params); // TODO: clarify which connection throws java.net.SocketTimeoutException? } catch (IOException e) { try { if (sslSocket != null) { sslSocket.close(); } if (socket != null) { socket.close(); } } catch (IOException ioe) {} throw e; } } @Override public void updateSecureConnection( final OperatedClientConnection conn, final HttpHost target, final HttpContext context, final HttpParams params) throws IOException { throw new RuntimeException("operation not supported"); } @Override protected InetAddress[] resolveHostname(final String host) throws UnknownHostException { throw new RuntimeException("operation not supported"); } } }