Implement TLS support (fixes #19)

This commit is contained in:
Lode Hoste 2015-03-27 23:29:55 +01:00
parent 7ddee2f953
commit c7cdfa1ff2
12 changed files with 225 additions and 27 deletions

View File

@ -43,7 +43,7 @@ public class GetTaskTest extends AndroidTestCase {
@MediumTest
public void testGetNoParams() throws IOException, InterruptedException {
new GetTask() {
new GetTask("") {
@Override
protected void onPostExecute(String s) {
assertEquals(RESPONSE, s);
@ -57,7 +57,7 @@ public class GetTaskTest extends AndroidTestCase {
@MediumTest
public void testGetParams() throws IOException, InterruptedException {
new GetTask() {
new GetTask("") {
@Override
protected void onPostExecute(String s) {
assertEquals(RESPONSE, s);

View File

@ -33,8 +33,10 @@ public class PollWebGuiAvailableTaskTest extends AndroidTestCase {
new SyncthingRunnable(new MockContext(null),
getContext().getApplicationInfo().dataDir + "/" + SyncthingService.BINARY_NAME);
String httpsCertPath = getContext().getFilesDir() + "/" + SyncthingService.HTTPS_CERT_FILE;
final CountDownLatch latch = new CountDownLatch(1);
new PollWebGuiAvailableTask() {
new PollWebGuiAvailableTask(httpsCertPath) {
@Override
protected void onPostExecute(Void aVoid) {
latch.countDown();

View File

@ -23,6 +23,8 @@ public class RestApiTest extends AndroidTestCase {
private RestApi mApi;
private String mHttpsCertPath;
@Override
protected void setUp() throws Exception {
super.setUp();
@ -33,8 +35,10 @@ public class RestApiTest extends AndroidTestCase {
mConfig = new ConfigXml(new MockContext(getContext()));
mConfig.changeDefaultFolder();
mHttpsCertPath = getContext().getFilesDir() + "/" + SyncthingService.HTTPS_CERT_FILE;
final CountDownLatch latch = new CountDownLatch(2);
new PollWebGuiAvailableTask() {
new PollWebGuiAvailableTask(mHttpsCertPath) {
@Override
protected void onPostExecute(Void aVoid) {
mApi.onWebGuiAvailable();

View File

@ -36,7 +36,7 @@ public class ConfigXmlTest extends AndroidTestCase {
@SmallTest
public void testGetWebGuiUrl() {
assertTrue(mConfig.getWebGuiUrl().startsWith("http://127.0.0.1:"));
assertTrue(mConfig.getWebGuiUrl().startsWith("https://127.0.0.1:"));
}
/**

View File

@ -3,13 +3,14 @@ package com.nutomic.syncthingandroid.syncthing;
import android.os.AsyncTask;
import android.util.Log;
import com.nutomic.syncthingandroid.util.Https;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HTTP;
@ -40,6 +41,12 @@ public class GetTask extends AsyncTask<String, Void, String> {
public static final String URI_DEVICEID = "/rest/deviceid";
private String mHttpsCertPath;
public GetTask(String httpsCertPath) {
mHttpsCertPath = httpsCertPath;
}
/**
* params[0] Syncthing hostname
* params[1] URI to call
@ -52,7 +59,7 @@ public class GetTask extends AsyncTask<String, Void, String> {
// Retry at most 10 times before failing
for (int i = 0; i < 10; i++) {
String fullUri = params[0] + params[1];
HttpClient httpclient = new DefaultHttpClient();
HttpClient httpclient = Https.createHttpsClient(mHttpsCertPath);
if (params.length == 5) {
LinkedList<NameValuePair> urlParams = new LinkedList<>();
urlParams.add(new BasicNameValuePair(params[3], params[4]));
@ -77,7 +84,7 @@ public class GetTask extends AsyncTask<String, Void, String> {
br.close();
return result;
}
} catch (IOException e) {
} catch (IOException|IllegalArgumentException e) {
Log.w(TAG, "Failed to call Rest API at " + fullUri, e);
}
try {

View File

@ -4,12 +4,13 @@ package com.nutomic.syncthingandroid.syncthing;
import android.os.AsyncTask;
import android.util.Log;
import com.nutomic.syncthingandroid.util.Https;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.conn.HttpHostConnectException;
import org.apache.http.impl.client.DefaultHttpClient;
import java.io.IOException;
@ -27,13 +28,19 @@ public abstract class PollWebGuiAvailableTask extends AsyncTask<String, Void, Vo
*/
private static final long WEB_GUI_POLL_INTERVAL = 100;
private String mHttpsCertPath;
public PollWebGuiAvailableTask(String httpsCertPath) {
mHttpsCertPath = httpsCertPath;
}
/**
* @param url The URL of the web GUI (eg 127.0.0.1:8384).
* @param @url The URL of the web GUI (eg 127.0.0.1:8384).
*/
@Override
protected Void doInBackground(String... url) {
int status = 0;
HttpClient httpclient = new DefaultHttpClient();
HttpClient httpclient = Https.createHttpsClient(mHttpsCertPath);
HttpHead head = new HttpHead(url[0]);
do {
try {
@ -45,7 +52,7 @@ public abstract class PollWebGuiAvailableTask extends AsyncTask<String, Void, Vo
} catch (HttpHostConnectException e) {
// We catch this in every call, as long as the service is not online,
// so we ignore and continue.
} catch (IOException|InterruptedException e) {
} catch (IOException|InterruptedException|IllegalArgumentException e) {
Log.w(TAG, "Failed to poll for web interface", e);
}
} while (status != HttpStatus.SC_OK && status != HttpStatus.SC_UNAUTHORIZED);

View File

@ -3,10 +3,11 @@ package com.nutomic.syncthingandroid.syncthing;
import android.os.AsyncTask;
import android.util.Log;
import com.nutomic.syncthingandroid.util.Https;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicHeader;
import org.apache.http.protocol.HTTP;
@ -23,6 +24,12 @@ public class PostTask extends AsyncTask<String, Void, Boolean> {
public static final String URI_SCAN = "/rest/scan";
private String mHttpsCertPath;
public PostTask(String httpsCertPath) {
mHttpsCertPath = httpsCertPath;
}
/**
* params[0] Syncthing hostname
* params[1] URI to call
@ -32,7 +39,7 @@ public class PostTask extends AsyncTask<String, Void, Boolean> {
@Override
protected Boolean doInBackground(String... params) {
String fullUri = params[0] + params[1];
HttpClient httpclient = new DefaultHttpClient();
HttpClient httpclient = Https.createHttpsClient(mHttpsCertPath);
HttpPost post = new HttpPost(fullUri);
post.addHeader(new BasicHeader(RestApi.HEADER_API_KEY, params[2]));
@ -41,7 +48,7 @@ public class PostTask extends AsyncTask<String, Void, Boolean> {
post.setEntity(new StringEntity(params[3], HTTP.UTF_8));
}
httpclient.execute(post);
} catch (IOException e) {
} catch (IOException|IllegalArgumentException e) {
Log.w(TAG, "Failed to call Rest API at " + fullUri, e);
return false;
}

View File

@ -151,7 +151,9 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
private final String mUrl;
private String mApiKey;
private final String mApiKey;
private final String mHttpsCertPath;
private JSONObject mConfig;
@ -180,6 +182,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
mContext = context;
mUrl = url;
mApiKey = apiKey;
mHttpsCertPath = mContext.getFilesDir() + "/" + SyncthingService.HTTPS_CERT_FILE;
mOnApiAvailableListener = listener;
}
@ -197,7 +200,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
public void onApiAvailable();
}
private OnApiAvailableListener mOnApiAvailableListener;
private final OnApiAvailableListener mOnApiAvailableListener;
/**
* Gets local device id, syncthing version and config, then calls all OnApiAvailableListeners.
@ -205,7 +208,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
@Override
public void onWebGuiAvailable() {
mAvailableCount.set(0);
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String s) {
try {
@ -219,7 +222,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
}
}
}.execute(mUrl, GetTask.URI_VERSION, mApiKey);
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String config) {
try {
@ -340,7 +343,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
*/
@TargetApi(11)
public void requireRestart(Activity activity) {
new PostTask().execute(mUrl, PostTask.URI_CONFIG, mApiKey, mConfig.toString());
new PostTask(mHttpsCertPath).execute(mUrl, PostTask.URI_CONFIG, mApiKey, mConfig.toString());
if (mRestartPostponed)
return;
@ -438,7 +441,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
* @param listener Callback invoked when the result is received.
*/
public void getSystemInfo(final OnReceiveSystemInfoListener listener) {
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String s) {
if (s == null)
@ -557,7 +560,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
* Use the key {@link #LOCAL_DEVICE_CONNECTIONS} to get connection info for the local device.
*/
public void getConnections(final OnReceiveConnectionsListener listener) {
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String s) {
if (s == null)
@ -650,7 +653,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
* Returns status information about the folder with the given ID.
*/
public void getModel(final String folderId, final OnReceiveModelListener listener) {
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String s) {
if (s == null)
@ -877,7 +880,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
* Normalizes a given device ID.
*/
public void normalizeDeviceId(String id, final OnDeviceIdNormalizedListener listener) {
new GetTask() {
new GetTask(mHttpsCertPath) {
@Override
protected void onPostExecute(String s) {
super.onPostExecute(s);
@ -934,7 +937,7 @@ public class RestApi implements SyncthingService.OnWebGuiAvailableListener,
*/
@Override
public void onFolderFileChange(String folderId, String relativePath) {
new PostTask().execute(mUrl, PostTask.URI_SCAN, mApiKey, "folder", folderId, "sub",
new PostTask(mHttpsCertPath).execute(mUrl, PostTask.URI_SCAN, mApiKey, "folder", folderId, "sub",
relativePath);
}

View File

@ -65,6 +65,11 @@ public class SyncthingService extends Service {
*/
public static final String PRIVATE_KEY_FILE = "key.pem";
/**
* Name of the public HTTPS CA file in the data directory.
*/
public static final String HTTPS_CERT_FILE = "https-cert.pem";
/**
* Directory where config is exported to and imported from.
*/
@ -195,7 +200,7 @@ public class SyncthingService extends Service {
mConfig = new ConfigXml(SyncthingService.this);
mCurrentState = State.STARTING;
registerOnWebGuiAvailableListener(mApi);
new PollWebGuiAvailableTaskImpl().execute(mConfig.getWebGuiUrl());
new PollWebGuiAvailableTaskImpl(getFilesDir() + "/" + HTTPS_CERT_FILE).execute(mConfig.getWebGuiUrl());
new Thread(new SyncthingRunnable(
this, getApplicationInfo().dataDir + "/" + BINARY_NAME)).start();
Notification n = new NotificationCompat.Builder(this)
@ -357,6 +362,11 @@ public class SyncthingService extends Service {
}
private class PollWebGuiAvailableTaskImpl extends PollWebGuiAvailableTask {
public PollWebGuiAvailableTaskImpl(String httpsCertPath) {
super(httpsCertPath);
}
@Override
protected void onPostExecute(Void aVoid) {
if (mStopScheduled) {

View File

@ -88,7 +88,7 @@ public class ConfigXml {
}
public String getWebGuiUrl() {
return "http://" + getGuiElement().getElementsByTagName("address").item(0).getTextContent();
return "https://" + getGuiElement().getElementsByTagName("address").item(0).getTextContent();
}
public String getApiKey() {

View File

@ -0,0 +1,105 @@
package com.nutomic.syncthingandroid.util;
import android.util.Log;
import org.apache.http.conn.ssl.SSLSocketFactory;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.security.InvalidKeyException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SignatureException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.X509TrustManager;
/*
* TrustManager allowing the Syncthing https.pem CA
*
* Based on http://stackoverflow.com/questions/16719959#16759793
*
*/
public class CustomX509TrustManager implements X509TrustManager {
private static final String TAG = "CustomX509TrustManager";
/**
* Taken from: http://janis.peisenieks.lv/en/76/english-making-an-ssl-connection-via-android/
*
*/
public static class CustomSSLSocketFactory extends SSLSocketFactory {
SSLContext sslContext = SSLContext.getInstance("TLS");
public CustomSSLSocketFactory(SSLContext context)
throws KeyManagementException, NoSuchAlgorithmException,
KeyStoreException, UnrecoverableKeyException {
super(null);
sslContext = context;
}
@Override
public Socket createSocket(Socket socket, String host, int port,
boolean autoClose) throws IOException {
return sslContext.getSocketFactory().createSocket(socket, host, port,
autoClose);
}
@Override
public Socket createSocket() throws IOException {
return sslContext.getSocketFactory().createSocket();
}
}
private String mHttpsCertPath;
public CustomX509TrustManager(String httpsCertPath) {
mHttpsCertPath = httpsCertPath;
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
}
/**
* Verifies certs against public key of the local syncthing instance
*/
@Override
public void checkServerTrusted(java.security.cert.X509Certificate[] certs,
String authType) throws CertificateException {
InputStream inStream = null;
try {
inStream = new FileInputStream(mHttpsCertPath);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate ca = (X509Certificate)
cf.generateCertificate(inStream);
for (X509Certificate cert : certs) {
cert.verify(ca.getPublicKey());
}
} catch (FileNotFoundException |NoSuchAlgorithmException|InvalidKeyException|
NoSuchProviderException |SignatureException e) {
throw new CertificateException("Untrusted Certificate!", e);
} finally {
try {
if (inStream != null)
inStream.close();
} catch (IOException e) {
Log.w(TAG, e);
}
}
}
public X509Certificate[] getAcceptedIssuers() {
return null;
}
}

View File

@ -0,0 +1,53 @@
package com.nutomic.syncthingandroid.util;
import android.util.Log;
import org.apache.http.client.HttpClient;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.UnrecoverableKeyException;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
/*
* Wrapper for HTTPS Clients allowing the Syncthing https.pem CA
*
*/
public class Https {
private static final String TAG = "HTTPS";
/**
* Create a HTTPClient that verifies a custom PEM certificate
*
* @param httpsCertPath refers to the filepath of a SSL/TLS PEM certificate.
*/
public static HttpClient createHttpsClient(String httpsCertPath) {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[] { new CustomX509TrustManager(httpsCertPath) },
new SecureRandom());
HttpClient client = new DefaultHttpClient();
SSLSocketFactory ssf = new CustomX509TrustManager.CustomSSLSocketFactory(ctx);
ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
ClientConnectionManager ccm = client.getConnectionManager();
SchemeRegistry sr = ccm.getSchemeRegistry();
sr.register(new Scheme("https", ssf, 443));
return new DefaultHttpClient(ccm,
client.getParams());
} catch (NoSuchAlgorithmException|KeyManagementException|KeyStoreException|
UnrecoverableKeyException e) {
Log.w(TAG, e);
}
return null;
}
}