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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,13 @@ package com.nutomic.syncthingandroid.syncthing;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.util.Log; import android.util.Log;
import com.nutomic.syncthingandroid.util.Https;
import org.apache.http.HttpResponse; import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus; import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient; import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpHead; import org.apache.http.client.methods.HttpHead;
import org.apache.http.conn.HttpHostConnectException; import org.apache.http.conn.HttpHostConnectException;
import org.apache.http.impl.client.DefaultHttpClient;
import java.io.IOException; 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 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 @Override
protected Void doInBackground(String... url) { protected Void doInBackground(String... url) {
int status = 0; int status = 0;
HttpClient httpclient = new DefaultHttpClient(); HttpClient httpclient = Https.createHttpsClient(mHttpsCertPath);
HttpHead head = new HttpHead(url[0]); HttpHead head = new HttpHead(url[0]);
do { do {
try { try {
@ -45,7 +52,7 @@ public abstract class PollWebGuiAvailableTask extends AsyncTask<String, Void, Vo
} catch (HttpHostConnectException e) { } catch (HttpHostConnectException e) {
// We catch this in every call, as long as the service is not online, // We catch this in every call, as long as the service is not online,
// so we ignore and continue. // so we ignore and continue.
} catch (IOException|InterruptedException e) { } catch (IOException|InterruptedException|IllegalArgumentException e) {
Log.w(TAG, "Failed to poll for web interface", e); Log.w(TAG, "Failed to poll for web interface", e);
} }
} while (status != HttpStatus.SC_OK && status != HttpStatus.SC_UNAUTHORIZED); } 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.os.AsyncTask;
import android.util.Log; import android.util.Log;
import com.nutomic.syncthingandroid.util.Https;
import org.apache.http.client.HttpClient; import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity; import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicHeader;
import org.apache.http.protocol.HTTP; 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"; public static final String URI_SCAN = "/rest/scan";
private String mHttpsCertPath;
public PostTask(String httpsCertPath) {
mHttpsCertPath = httpsCertPath;
}
/** /**
* params[0] Syncthing hostname * params[0] Syncthing hostname
* params[1] URI to call * params[1] URI to call
@ -32,7 +39,7 @@ public class PostTask extends AsyncTask<String, Void, Boolean> {
@Override @Override
protected Boolean doInBackground(String... params) { protected Boolean doInBackground(String... params) {
String fullUri = params[0] + params[1]; String fullUri = params[0] + params[1];
HttpClient httpclient = new DefaultHttpClient(); HttpClient httpclient = Https.createHttpsClient(mHttpsCertPath);
HttpPost post = new HttpPost(fullUri); HttpPost post = new HttpPost(fullUri);
post.addHeader(new BasicHeader(RestApi.HEADER_API_KEY, params[2])); 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)); post.setEntity(new StringEntity(params[3], HTTP.UTF_8));
} }
httpclient.execute(post); httpclient.execute(post);
} catch (IOException e) { } catch (IOException|IllegalArgumentException e) {
Log.w(TAG, "Failed to call Rest API at " + fullUri, e); Log.w(TAG, "Failed to call Rest API at " + fullUri, e);
return false; return false;
} }

View File

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

View File

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

View File

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