/*
* Copyright (c) 2014 The MITRE Corporation, All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this work except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mitre.svmp.apprtc;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Log;
import com.google.protobuf.InvalidProtocolBufferException;
import de.tavendo.autobahn.WebSocket;
import de.tavendo.autobahn.WebSocketConnection;
import de.tavendo.autobahn.WebSocketException;
import de.tavendo.autobahn.WebSocketOptions;
import org.apache.http.*;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.HTTP;
import org.json.JSONException;
import org.json.JSONObject;
import org.mitre.svmp.common.SessionInfo;
import org.mitre.svmp.net.SSLConfig;
import org.mitre.svmp.performance.PerformanceTimer;
import org.mitre.svmp.services.SessionService;
import org.mitre.svmp.activities.AppRTCActivity;
import org.mitre.svmp.auth.AuthData;
import org.mitre.svmp.client.R;
import org.mitre.svmp.common.*;
import org.mitre.svmp.protocol.SVMPProtocol.*;
import org.mitre.svmp.common.StateMachine.STATE;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLSocket;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
/**
* @author Joe Portner
*
* Negotiates signaling for chatting with apprtc.appspot.com "rooms".
* Uses the client<->server specifics of the apprtc AppEngine webapp.
*
* Now extended to act as a Binder object between a Service and an Activity.
*
* To use: create an instance of this object (registering a message handler) and
* call connectToRoom(). Once that's done call sendMessage() and wait for the
* registered handler to be called with received messages.
*/
public class AppRTCClient extends Binder implements Constants {
private static final String TAG = AppRTCClient.class.getName();
// service and activity objects
private StateMachine machine;
private SessionService service = null;
private AppRTCActivity activity = null;
// common variables
private ConnectionInfo connectionInfo;
private SessionInfo sessionInfo;
private DatabaseHandler dbHandler;
private boolean init = false; // switched to 'true' when activity first binds
private boolean proxying = false; // switched to 'true' upon state machine change
// performance instrumentation
private PerformanceTimer performance;
// variables for networking
private boolean useSSL;
private SSLConfig sslConfig;
private Socket socket;
private SocketHandlerThread socketHandlerThread;
private WebSocketConnection webSocket;
// STEP 0: NEW -> STARTED
public AppRTCClient(SessionService service, StateMachine machine, ConnectionInfo connectionInfo) {
this.service = service;
this.machine = machine;
machine.addObserver(service);
this.connectionInfo = connectionInfo;
this.dbHandler = new DatabaseHandler(service);
this.performance = new PerformanceTimer(service, this, connectionInfo.getConnectionID());
machine.setState(STATE.STARTED, 0);
}
// called from activity
public void connectToRoom(AppRTCActivity activity) {
this.activity = activity;
machine.addObserver(activity);
// we don't initialize the SocketConnector until the activity first binds; mitigates concurrency issues
if (!init) {
init = true;
int error = 0;
// determine whether we should use SSL from the EncryptionType integer
useSSL = connectionInfo.getEncryptionType() == Constants.ENCRYPTION_SSLTLS;
if (useSSL) {
sslConfig = new SSLConfig(connectionInfo, activity);
error = sslConfig.configure();
}
if (error == 0)
login();
else
machine.setState(STATE.ERROR, error);
}
// if the state is already running, we are reconnecting
else if (machine.getState() == STATE.RUNNING) {
activity.onOpen();
}
}
// called from activity
public void disconnectFromRoom() {
machine.removeObserver(activity);
this.activity = null;
}
public boolean isBound() {
return this.activity != null;
}
public PerformanceTimer getPerformance() {
return performance;
}
public AppRTCSignalingParameters getSignalingParams() {
return sessionInfo.getSignalingParams();
}
// called from AppRTCActivity
public void changeToErrorState() {
machine.setState(STATE.ERROR, R.string.appRTC_toast_connection_finish);
}
public void disconnect() {
proxying = false;
// we're disconnecting, update the database record with the current timestamp
dbHandler.close();
performance.cancel(); // stop taking performance measurements
// shut down the WebSocket if it's open
if (webSocket != null && webSocket.isConnected())
webSocket.disconnect();
if (socketHandlerThread != null)
socketHandlerThread.quitSafely();
}
public synchronized void sendMessage(Request msg) {
if (proxying) {
//webSocket.sendBinaryMessage(msg.toByteArray());
// VM is expecting a message delimiter (varint prefix) so write a delimited message instead
try {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
msg.writeDelimitedTo(stream);
webSocket.sendBinaryMessage(stream.toByteArray());
} catch (IOException e) {
Log.e(TAG, "Error writing delimited byte output:", e);
}
}
}
// STEP 1: STARTED -> AUTH, Authenticate with the SVMP login REST service
private class SVMPAuthenticator extends AsyncTask<JSONObject, Void, Integer> {
private boolean passwordChange;
@Override
protected Integer doInBackground(JSONObject... jsonObjects) {
int returnVal = R.string.appRTC_toast_socketConnector_fail; // generic error message
JSONObject jsonRequest = jsonObjects[0];
passwordChange = jsonRequest.has("newPassword");
int rPort = connectionInfo.getPort();
String proto = useSSL ? "https" : "http",
rHost = connectionInfo.getHost(),
// if we're changing our password, use a different API
api = passwordChange ? "changePassword" : "login",
uri = String.format("%s://%s:%d/%s", proto, rHost, rPort, api);
// set up HttpParams
HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);
// set up ConnectionManager
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme(proto, useSSL ? sslConfig.getSocketFactory() : PlainSocketFactory.getSocketFactory(), rPort));
ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);
// create HttpClient
DefaultHttpClient httpclient = new DefaultHttpClient(ccm, params);
HttpPost post = new HttpPost(uri);
post.setHeader(HTTP.CONTENT_TYPE, "application/json");
try {
StringEntity entity = new StringEntity(jsonRequest.toString());
post.setEntity(entity);
HttpResponse response = httpclient.execute(post);
int responseCode = response.getStatusLine().getStatusCode();
if (responseCode == 200) { // "OK", code for AUTH_OK
// get JSON object
ByteArrayOutputStream out = new ByteArrayOutputStream();
response.getEntity().writeTo(out);
out.close();
JSONObject jsonResponse = new JSONObject(out.toString());
// get session info
String token = jsonResponse.getJSONObject("sessionInfo").getString("token");
long expires = new Date().getTime() + (1000 * jsonResponse.getJSONObject("sessionInfo").getInt("maxLength"));
String host = jsonResponse.getJSONObject("server").getString("host");
String port = jsonResponse.getJSONObject("server").getString("port");
JSONObject webrtc = jsonResponse.getJSONObject("webrtc");
sessionInfo = new SessionInfo(token, expires, host, port, webrtc);
if (sessionInfo.getSignalingParams() != null)
returnVal = 0; // success
}
else if (responseCode == 403) { // "Forbidden", code for NEED_PASSWORD_CHANGE
returnVal = R.string.svmpActivity_toast_needPasswordChange;
}
else if ((responseCode == 400 || responseCode == 401) && !passwordChange) { // "Unauthorized", code for AUTH_FAIL
returnVal = R.string.appRTC_toast_svmpAuthenticator_fail;
}
else if (responseCode == 400 || responseCode == 401) { // "Unauthorized", code for PASSWORD_CHANGE_FAIL
returnVal = R.string.appRTC_toast_svmpAuthenticator_passwordChangeFail;
}
else if (responseCode == 404) { // "Not Found"
returnVal = R.string.appRTC_toast_socketConnector_404;
}
} catch (JSONException e) {
Log.e(TAG, "Failed to parse JSON response:", e);
} catch (SSLHandshakeException e) {
String msg = e.getMessage();
if (msg.contains("java.security.cert.CertPathValidatorException")) {
// the server's certificate isn't in our trust store
Log.e(TAG, "Untrusted server certificate!");
returnVal = R.string.appRTC_toast_socketConnector_failUntrustedServer;
} else {
Log.e(TAG, "Error during SSL handshake: " + e.getMessage());
returnVal = R.string.appRTC_toast_socketConnector_failSSLHandshake;
}
} catch (SSLException e) {
if ("Connection closed by peer".equals(e.getMessage())) {
// connection failed, we tried to connect using SSL but REST API's SSL is turned off
Log.e(TAG, "Client encryption is on but server encryption is off:", e);
returnVal = R.string.appRTC_toast_socketConnector_failSSL;
}
else {
Log.e(TAG, "SSL error:", e);
}
} catch (NoHttpResponseException e) {
if ("The target server failed to respond".equals(e.getMessage())) {
// connection failed, we tried to connect without using SSL but REST API's SSL is turned on
Log.e(TAG, "Client encryption is off but server encryption is on:", e);
returnVal = R.string.appRTC_toast_socketConnector_failSSL;
}
else {
Log.e(TAG, "HTTP request failed:", e);
}
} catch (IOException e) {
Log.e(TAG, "HTTP request failed:", e);
}
return returnVal;
}
@Override
protected void onPostExecute(Integer result) {
if (result == 0) { // success, start the next phase and connect to the SVMP proxy server
dbHandler.updateSessionInfo(connectionInfo, sessionInfo);
machine.setState(STATE.AUTH, R.string.appRTC_toast_svmpAuthenticator_success); // STARTED -> AUTH
connect();
} else {
// authentication failed, handle appropriately
machine.setState(STATE.ERROR, result); // STARTED -> ERROR
}
}
}
public void login() {
// attempt to get any existing auth data JSONObject that's in memory (e.g. made of user input such as password)
JSONObject jsonObject = AuthData.getJSON(connectionInfo);
if (jsonObject != null) {
// execute async HTTP request to the REST auth API
(new SVMPAuthenticator()).execute(jsonObject);
}
else {
sessionInfo = dbHandler.getSessionInfo(connectionInfo);
if (sessionInfo != null) {
// we've already authenticated, we can connect directly to the proxy
machine.setState(STATE.AUTH, R.string.appRTC_toast_svmpAuthenticator_bypassed); // STARTED -> AUTH
connect();
}
else {
Log.e(TAG, "login failed: no auth data or session info found");
machine.setState(STATE.ERROR, R.string.appRTC_toast_connection_finish);
}
}
}
// STEP 2: AUTH -> CONNECTED, Connect to the SVMP proxy service
public void connect() {
new SocketConnector().execute();
}
private class SocketConnector extends AsyncTask<Void, Void, Integer> {
@Override
protected Integer doInBackground(Void... params) {
int returnVal = R.string.appRTC_toast_socketConnector_fail; // resID for return message
try {
// create the socket for the WebSocketConnection to use
// we do this here because the Looping and Handling that takes place in the WebSocket code causes
// the app to freeze when any other processes are launched (such as KeyChain or MemorizingTrustManager)
javax.net.SocketFactory factory;
if (useSSL) {
factory = sslConfig.getSSLContext().getSocketFactory();
}
else {
factory = javax.net.SocketFactory.getDefault();
}
socket = factory.createSocket(sessionInfo.getHost(), Integer.parseInt(sessionInfo.getPort()));
if (useSSL) {
SSLSocket sslSocket = (SSLSocket)socket;
sslSocket.setEnabledProtocols(ENABLED_PROTOCOLS);
sslSocket.setEnabledCipherSuites(ENABLED_CIPHERS);
sslSocket.startHandshake(); // starts the handshake to verify the cert before continuing
}
//socket.setTcpNoDelay(true);
// if we made it to this point, return a success message
returnVal = 0;
} catch (SSLHandshakeException e) {
String msg = e.getMessage();
if (msg.contains("java.security.cert.CertPathValidatorException")) {
// the server's certificate isn't in our trust store
Log.e(TAG, "Untrusted server certificate!");
returnVal = R.string.appRTC_toast_socketConnector_failUntrustedServer;
} else {
Log.e(TAG, "Error during SSL handshake: " + e.getMessage());
returnVal = R.string.appRTC_toast_socketConnector_failSSLHandshake;
}
} catch (SSLException e) {
if ("Connection closed by peer".equals(e.getMessage())) {
// connection failed, we tried to connect using SSL but REST API's SSL is turned off
Log.e(TAG, "Client encryption is on but server encryption is off:", e);
returnVal = R.string.appRTC_toast_socketConnector_failSSL;
}
else {
Log.e(TAG, "SSL error:", e);
}
} catch (Exception e) {
Log.e(TAG, "Exception: " + e.getMessage());
e.printStackTrace();
}
return returnVal;
}
@Override
protected void onPostExecute(Integer result) {
if (result != 0) {
machine.setState(STATE.ERROR, result); // STARTED -> ERROR
} else {
// we have to run the WebSocket connection in a HandlerThread to ensure that Looper is prepared
// properly and that the MemorizingTrustManager doesn't execute on the main UI thread
socketHandlerThread = new SocketHandlerThread("svmp-websocket-" + new Date().getTime());
socketHandlerThread.start();
}
}
}
private class SocketHandlerThread extends HandlerThread {
public SocketHandlerThread(String name) {
super(name);
}
@Override
protected void onLooperPrepared() {
// set up the WebSocket URI for the svmp-server
String proto = useSSL ? "wss" : "ws";
URI uri = URI.create(String.format("%s://%s:%s", proto, sessionInfo.getHost(), sessionInfo.getPort()));
Log.d(TAG, "Socket connecting to " + uri.toString());
// set up the WebSocket options for the svmp-server
WebSocketOptions options = new WebSocketOptions();
options.setMaxFramePayloadSize(8 * 128 * 1024); // increase max frame size to handle high-res icons
HashMap<String, String> headers = new HashMap<String, String>();
// HACK: JavaScript WebSocket API doesn't allow for custom headers, so we repurpose this header instead
// We set it here instead of the constructor because this doesn't append a comma suffix
headers.put("Sec-WebSocket-Protocol", sessionInfo.getToken());
options.setHeaders(headers);
// we have the socket and the SSL handshake has completed
// now establish a WebSocketConnection
try {
webSocket = new WebSocketConnection();
webSocket.connect(socket, uri, null, observer, options);
} catch (WebSocketException e) {
Log.e(TAG, "Failed to connect to SVMP proxy:", e);
machine.setState(STATE.ERROR, R.string.appRTC_toast_socketConnector_fail);
}
}
}
WebSocket.WebSocketConnectionObserver observer = new WebSocket.WebSocketConnectionObserver() {
private boolean hasVMREADY;
@Override
public void onOpen() {
Log.i(TAG, "WebSocket connected.");
machine.setState(STATE.CONNECTED, R.string.appRTC_toast_socketConnector_success); // AUTH -> CONNECTED
// wait for VMREADY
}
@Override
public void onClose(WebSocketCloseNotification code, String reason) {
if (proxying || machine.getState() == STATE.AUTH || machine.getState() == STATE.CONNECTED) {
// either we were disconnected unexpectedly, or the connection was never successfully established
// we haven't called disconnect(), this was an error; log this as an Error message and change state
changeToErrorState();
Log.e(TAG, "WebSocket disconnected: " + code.toString() + ", " + reason);
}
else // we called disconnect(), this was intentional; log this as an Info message
Log.i(TAG, "WebSocket disconnected.");
}
@Override
public void onTextMessage(String payload) {}
@Override
public void onRawTextMessage(byte[] payload) {}
@Override
public void onBinaryMessage(byte[] payload) {
try {
Response data = Response.parseFrom(payload);
Log.d(TAG, "Received incoming message object of type " + data.getType().name());
onResponse(data);
} catch (InvalidProtocolBufferException e) {
Log.e(TAG, "Unable to parse protobuf:", e);
changeToErrorState();
}
}
private void onResponse(Response data) {
if (data.getType() == Response.ResponseType.ERROR) {
Log.e(TAG, "Received ERROR message");
int error = hasVMREADY ? R.string.appRTC_toast_connection_finish : R.string.appRTC_toast_svmpReadyWait_fail;
machine.setState(STATE.ERROR, error);
}
else if (!hasVMREADY) // we are in the CONNECTED state, waiting for VMREADY
onResponseCONNECTED(data);
else // we are in the RUNNING state
onResponseRUNNING(data);
}
// STEP 3: CONNECTED -> RUNNING, Receive VMREADY message
private void onResponseCONNECTED(Response data) {
int error = R.string.appRTC_toast_connection_finish; // generic error message
// generate a status code
Response.ResponseType type = data.getType();
if (type == Response.ResponseType.VMREADY)
error = 0;
else if (type == Response.ResponseType.AUTH && data.getAuthResponse().getType() == AuthResponse.AuthResponseType.AUTH_FAIL)
error = R.string.appRTC_toast_svmpAuthenticator_fail;
// any other message type throws us into an error state
// act on the status code
if (error == 0) { // success
hasVMREADY = true;
machine.setState(STATE.RUNNING, R.string.appRTC_toast_svmpReadyWait_success); // CONNECTED -> RUNNING
proxying = true;
// callbacks to the service and activity to let them know the connection has started
service.onOpen();
if (isBound())
activity.onOpen();
// start taking performance measurements
performance.start();
}
else // fail with the appropriate error message
machine.setState(STATE.ERROR, error);
}
// STEP 4: RUNNING
private void onResponseRUNNING(final Response data) {
boolean consumed = service.onMessage(data);
if (!consumed && isBound()) {
activity.runOnUiThread(new Runnable() {
public void run() {
activity.onMessage(data);
}
});
}
}
};
}