diff --git a/device-transfer/app/build.gradle b/device-transfer/app/build.gradle
index 6bbc6ff010..b2f6d3e77f 100644
--- a/device-transfer/app/build.gradle
+++ b/device-transfer/app/build.gradle
@@ -17,6 +17,25 @@ android {
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
+
+ buildTypes {
+ debug {
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android.txt'),
+ 'proguard/proguard.cfg'
+ }
+
+ release {
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android.txt'),
+ 'proguard/proguard.cfg'
+ }
+ }
+
+ packagingOptions {
+ exclude '/org/spongycastle/x509/CertPathReviewerMessages.properties'
+ exclude '/org/spongycastle/x509/CertPathReviewerMessages_de.properties'
+ }
}
dependencies {
diff --git a/device-transfer/app/proguard/proguard.cfg b/device-transfer/app/proguard/proguard.cfg
new file mode 100644
index 0000000000..c6e97d049a
--- /dev/null
+++ b/device-transfer/app/proguard/proguard.cfg
@@ -0,0 +1,7 @@
+-dontoptimize
+-dontobfuscate
+-keepattributes SourceFile,LineNumberTable
+-keep class org.signal.devicetransfer.** { *; }
+-keepclassmembers class ** {
+ public void onEvent*(**);
+}
diff --git a/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java b/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java
index 2f19ea0c3e..c090e93015 100644
--- a/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java
+++ b/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java
@@ -16,6 +16,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NotificationManagerCompat;
@@ -26,7 +27,7 @@ import org.signal.devicetransfer.ClientTask;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.DeviceToDeviceTransferService.TransferNotificationData;
import org.signal.devicetransfer.ServerTask;
-import org.signal.devicetransfer.TransferMode;
+import org.signal.devicetransfer.TransferStatus;
import java.io.IOException;
import java.io.InputStream;
@@ -57,7 +58,6 @@ public class MainActivity extends AppCompatActivity {
findViewById(R.id.start_server).setOnClickListener(v -> {
DeviceToDeviceTransferService.startServer(this,
- 8888,
new ServerReceiveRandomBytes(),
data,
PendingIntent.getActivity(this,
@@ -70,7 +70,6 @@ public class MainActivity extends AppCompatActivity {
findViewById(R.id.start_client).setOnClickListener(v -> {
DeviceToDeviceTransferService.startClient(this,
- 8888,
new ClientSendRandomBytes(),
data,
PendingIntent.getActivity(this,
@@ -81,9 +80,7 @@ public class MainActivity extends AppCompatActivity {
list.removeAllViews();
});
- findViewById(R.id.stop).setOnClickListener(v -> {
- DeviceToDeviceTransferService.stop(this);
- });
+ findViewById(R.id.stop).setOnClickListener(v -> DeviceToDeviceTransferService.stop(this));
findViewById(R.id.enable_permission).setOnClickListener(v -> {
if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
@@ -95,19 +92,27 @@ public class MainActivity extends AppCompatActivity {
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
- public void onEventMainThread(@NonNull TransferMode event) {
+ public void onEventMainThread(@NonNull TransferStatus event) {
TextView text = new TextView(this);
- text.setText(event.toString());
+ text.setText(event.getTransferMode().toString());
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
text.setLayoutParams(params);
list.addView(text);
+
+ if (event.getTransferMode() == TransferStatus.TransferMode.VERIFICATION_REQUIRED) {
+ new AlertDialog.Builder(this).setTitle("Verification Required")
+ .setMessage("Code: " + ((TransferStatus.VerificationTransferStatus) event).getAuthenticationCode())
+ .setPositiveButton("Yes, Same", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, true))
+ .setNegativeButton("No, different", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, false))
+ .setCancelable(false)
+ .show();
+ }
}
private static class ClientSendRandomBytes implements ClientTask {
- private static final String TAG = "ClientSend";
-
- private final int rounds = 1000;
+ private static final String TAG = "ClientSend";
+ private static final int ROUNDS = 131072 / 4; // Use 131072 to send 1GB
@Override
public void run(@NonNull Context context, @NonNull OutputStream outputStream) throws IOException {
@@ -116,14 +121,18 @@ public class MainActivity extends AppCompatActivity {
r.nextBytes(data);
long start = System.currentTimeMillis();
- Log.i(TAG, "Sending " + ((data.length * rounds) / 1024 / 1024) + "MB of random data!!!");
- for (int i = 0; i < rounds; i++) {
+ Log.i(TAG, "Sending " + ((data.length * ROUNDS) / 1024 / 1024) + "MB of random data!!!");
+ for (int i = 0; i < ROUNDS; i++) {
outputStream.write(data);
outputStream.flush();
}
long end = System.currentTimeMillis();
Log.i(TAG, "Sending took: " + (end - start));
}
+
+ @Override
+ public void success() {
+ }
}
private static class ServerReceiveRandomBytes implements ServerTask {
diff --git a/device-transfer/lib/build.gradle b/device-transfer/lib/build.gradle
index c4352837dc..5cf0e51d6c 100644
--- a/device-transfer/lib/build.gradle
+++ b/device-transfer/lib/build.gradle
@@ -2,6 +2,10 @@ apply plugin: 'com.android.library'
apply plugin: 'witness'
apply from: 'witness-verifications.gradle'
+repositories {
+ mavenCentral()
+}
+
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
@@ -9,6 +13,7 @@ android {
defaultConfig {
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
+ consumerProguardFiles 'lib-proguard-rules.pro'
}
compileOptions {
@@ -23,8 +28,18 @@ dependencyVerification {
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
- implementation 'com.google.android.material:material:1.2.1'
implementation project(':core-util')
+ implementation 'com.madgag.spongycastle:core:1.58.0.0'
+ implementation 'com.madgag.spongycastle:prov:1.58.0.0'
+ implementation 'com.madgag.spongycastle:pkix:1.54.0.0'
+ implementation 'com.madgag.spongycastle:pg:1.54.0.0'
api 'org.greenrobot:eventbus:3.0.0'
+
testImplementation 'junit:junit:4.12'
+ testImplementation 'androidx.test:core:1.2.0'
+ testImplementation ('org.robolectric:robolectric:4.4') {
+ exclude group: 'com.google.protobuf', module: 'protobuf-java'
+ }
+ testImplementation 'org.robolectric:shadows-multidex:4.4'
+ testImplementation 'org.hamcrest:hamcrest:2.2'
}
diff --git a/device-transfer/lib/lib-proguard-rules.pro b/device-transfer/lib/lib-proguard-rules.pro
new file mode 100644
index 0000000000..3540c440b6
--- /dev/null
+++ b/device-transfer/lib/lib-proguard-rules.pro
@@ -0,0 +1,8 @@
+-keep class org.spongycastle.jcajce.provider.digest.SHA256** {*;}
+-keepclassmembers class org.spongycastle.jcajce.provider.digest.SHA256** {*;}
+
+-keep class org.spongycastle.jcajce.provider.asymmetric.RSA**
+-keepclassmembers class org.spongycastle.jcajce.provider.asymmetric.RSA** {*;}
+
+-keep class org.spongycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi** {*;}
+-keepclassmembers class org.spongycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi** {*;}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ClientTask.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ClientTask.java
index b506e84068..8b66899085 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ClientTask.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ClientTask.java
@@ -19,4 +19,9 @@ public interface ClientTask extends Serializable {
* @param outputStream Output stream associated with socket connected to remote server.
*/
void run(@NonNull Context context, @NonNull OutputStream outputStream) throws IOException;
+
+ /**
+ * Called after the output stream has been successfully flushed and closed.
+ */
+ void success();
}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java
index f7486a889c..84cfdf6e2f 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java
@@ -29,16 +29,15 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
private static final String TAG = Log.tag(DeviceToDeviceTransferService.class);
- private static final int INVALID_PORT = -1;
-
private static final String ACTION_START_SERVER = "start";
private static final String ACTION_START_CLIENT = "start_client";
+ private static final String ACTION_SET_VERIFIED = "set_verified";
private static final String ACTION_STOP = "stop";
private static final String EXTRA_PENDING_INTENT = "extra_pending_intent";
private static final String EXTRA_TASK = "extra_task";
private static final String EXTRA_NOTIFICATION = "extra_notification_data";
- private static final String EXTRA_PORT = "extra_port";
+ private static final String EXTRA_IS_VERIFIED = "is_verified";
private TransferNotificationData notificationData;
private PendingIntent pendingIntent;
@@ -46,7 +45,6 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
private DeviceTransferClient client;
public static void startServer(@NonNull Context context,
- int port,
@NonNull ServerTask serverTask,
@NonNull TransferNotificationData transferNotificationData,
@Nullable PendingIntent pendingIntent)
@@ -54,7 +52,6 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
intent.setAction(ACTION_START_SERVER)
.putExtra(EXTRA_TASK, serverTask)
- .putExtra(EXTRA_PORT, port)
.putExtra(EXTRA_NOTIFICATION, transferNotificationData)
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
@@ -62,7 +59,6 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
}
public static void startClient(@NonNull Context context,
- int port,
@NonNull ClientTask clientTask,
@NonNull TransferNotificationData transferNotificationData,
@Nullable PendingIntent pendingIntent)
@@ -70,13 +66,20 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
intent.setAction(ACTION_START_CLIENT)
.putExtra(EXTRA_TASK, clientTask)
- .putExtra(EXTRA_PORT, port)
.putExtra(EXTRA_NOTIFICATION, transferNotificationData)
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
context.startService(intent);
}
+ public static void setAuthenticationCodeVerified(@NonNull Context context, boolean verified) {
+ Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
+ intent.setAction(ACTION_SET_VERIFIED)
+ .putExtra(EXTRA_IS_VERIFIED, verified);
+
+ context.startService(intent);
+ }
+
public static void stop(@NonNull Context context) {
context.startService(new Intent(context, DeviceToDeviceTransferService.class).setAction(ACTION_STOP));
}
@@ -90,14 +93,10 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
- public void onEventMainThread(@NonNull TransferMode event) {
+ public void onEventMainThread(@NonNull TransferStatus event) {
updateNotification(event);
}
- private void update(@NonNull TransferMode transferMode) {
- EventBus.getDefault().postSticky(transferMode);
- }
-
@Override
public void onDestroy() {
Log.e(TAG, "onDestroy");
@@ -105,12 +104,12 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
EventBus.getDefault().unregister(this);
if (client != null) {
- client.shutdown();
+ client.stop();
client = null;
}
if (server != null) {
- server.shutdown();
+ server.stop();
server = null;
}
@@ -130,45 +129,46 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
final WifiDirect.AvailableStatus availability = WifiDirect.getAvailability(this);
if (availability != WifiDirect.AvailableStatus.AVAILABLE) {
- update(availability == WifiDirect.AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED ? TransferMode.PERMISSIONS
- : TransferMode.UNAVAILABLE);
shutdown();
return START_NOT_STICKY;
}
+ Log.d(TAG, "Action: " + action);
switch (action) {
case ACTION_START_SERVER: {
- int port = intent.getIntExtra(EXTRA_PORT, INVALID_PORT);
- if (server == null && port != -1) {
+ if (server == null) {
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
server = new DeviceTransferServer(getApplicationContext(),
(ServerTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
- port,
this);
- updateNotification(TransferMode.READY);
server.start();
} else {
- Log.i(TAG, "Can't start server. already_started: " + (server != null) + " port: " + port);
+ Log.i(TAG, "Can't start server, already started.");
}
break;
}
case ACTION_START_CLIENT: {
- int port = intent.getIntExtra(EXTRA_PORT, INVALID_PORT);
- if (client == null && port != -1) {
+ if (client == null) {
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
client = new DeviceTransferClient(getApplicationContext(),
(ClientTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
- port,
this);
- updateNotification(TransferMode.READY);
client.start();
} else {
- Log.i(TAG, "Can't start client. already_started: " + (client != null) + " port: " + port);
+ Log.i(TAG, "Can't start client, client already started.");
}
break;
}
+ case ACTION_SET_VERIFIED:
+ boolean isVerified = intent.getBooleanExtra(EXTRA_IS_VERIFIED, false);
+ if (server != null) {
+ server.setVerified(isVerified);
+ } else if (client != null) {
+ client.setVerified(isVerified);
+ }
+ break;
case ACTION_STOP:
shutdown();
break;
@@ -186,20 +186,20 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa
});
}
- private void updateNotification(@NonNull TransferMode transferMode) {
+ private void updateNotification(@NonNull TransferStatus transferStatus) {
if (notificationData != null && (client != null || server != null)) {
- startForeground(notificationData.notificationId, createNotification(transferMode, notificationData));
+ startForeground(notificationData.notificationId, createNotification(transferStatus, notificationData));
}
}
- private @NonNull Notification createNotification(@NonNull TransferMode transferMode, @NonNull TransferNotificationData notificationData) {
+ private @NonNull Notification createNotification(@NonNull TransferStatus transferStatus, @NonNull TransferNotificationData notificationData) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationData.channelId);
//TODO [cody] build notification to spec
builder.setSmallIcon(notificationData.icon)
.setOngoing(true)
.setContentTitle("Device Transfer")
- .setContentText("Status: " + transferMode.name())
+ .setContentText("Status: " + transferStatus.getTransferMode().name())
.setContentIntent(pendingIntent);
return builder.build();
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferAuthentication.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferAuthentication.java
new file mode 100644
index 0000000000..75c1761140
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferAuthentication.java
@@ -0,0 +1,226 @@
+package org.signal.devicetransfer;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+
+import org.signal.core.util.StreamUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Allows two parties to authenticate each other via short authentication strings (SAS).
+ *
+ * - Client generates a random data, and then MAC(k=random data, m=certificate) to get a commitment.
+ * - Client sends commitment to the server.
+ * - Server stores commitment and generates it's own random data.
+ * - Server sends it's random data to client.
+ * - Client stores server random data and sends it's random data to the server.
+ * - Server can then MAC(k=client random data, m=certificate) to verify the original commitment.
+ * - Client and Server can compute a SAS using the two randoms.
+ *
+ */
+final class DeviceTransferAuthentication {
+
+ public static final int DIGEST_LENGTH = 32;
+ private static final String MAC_ALGORITHM = "HmacSHA256";
+
+ private DeviceTransferAuthentication() {}
+
+ /**
+ * Perform the client side of the SAS generation via input and output streams.
+ *
+ * @param certificate x509 certificate of the TLS connection
+ * @param inputStream stream to read data from the {@link Server}
+ * @param outputStream stream to write data to the {@link Server}
+ * @return Computed SAS
+ * @throws DeviceTransferAuthenticationException When something in the code generation fails
+ * @throws IOException When a communication issue occurs over one of the two streams
+ */
+ public static int generateClientAuthenticationCode(@NonNull byte[] certificate,
+ @NonNull InputStream inputStream,
+ @NonNull OutputStream outputStream)
+ throws DeviceTransferAuthenticationException, IOException
+ {
+ Client authentication = new Client(certificate);
+ outputStream.write(authentication.getCommitment());
+ outputStream.flush();
+
+ byte[] serverRandom = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
+ StreamUtil.readFully(inputStream, serverRandom, serverRandom.length);
+
+ byte[] clientRandom = authentication.setServerRandomAndGetClientRandom(serverRandom);
+ outputStream.write(clientRandom);
+ outputStream.flush();
+
+ return authentication.computeShortAuthenticationCode();
+ }
+
+ /**
+ * Perform the server side of the SAS generation via input and output streams.
+ *
+ * @param certificate x509 certificate of the TLS connection
+ * @param inputStream stream to read data from the {@link Client}
+ * @param outputStream stream to write data to the {@link Client}
+ * @return Computed SAS
+ * @throws DeviceTransferAuthenticationException When something in the code generation fails or the client
+ * provided commitment does not match the computed version
+ * @throws IOException When a communication issue occurs over one of the two streams
+ */
+ public static int generateServerAuthenticationCode(@NonNull byte[] certificate,
+ @NonNull InputStream inputStream,
+ @NonNull OutputStream outputStream)
+ throws DeviceTransferAuthenticationException, IOException
+ {
+ byte[] clientCommitment = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
+ StreamUtil.readFully(inputStream, clientCommitment, clientCommitment.length);
+
+ DeviceTransferAuthentication.Server authentication = new DeviceTransferAuthentication.Server(certificate, clientCommitment);
+
+ outputStream.write(authentication.getRandom());
+ outputStream.flush();
+
+ byte[] clientRandom = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
+ StreamUtil.readFully(inputStream, clientRandom, clientRandom.length);
+ authentication.setClientRandom(clientRandom);
+
+ return authentication.computeShortAuthenticationCode();
+ }
+
+ private static @NonNull Mac getMac(@NonNull byte[] secret) throws DeviceTransferAuthenticationException {
+ try {
+ Mac mac = Mac.getInstance(MAC_ALGORITHM);
+ mac.init(new SecretKeySpec(secret, MAC_ALGORITHM));
+ return mac;
+ } catch (Exception e) {
+ throw new DeviceTransferAuthenticationException(e);
+ }
+ }
+
+ private static int computeShortAuthenticationCode(@NonNull byte[] clientRandom,
+ @NonNull byte[] serverRandom)
+ throws DeviceTransferAuthenticationException
+ {
+ byte[] authentication = getMac(clientRandom).doFinal(serverRandom);
+
+ ByteBuffer buffer = ByteBuffer.wrap(authentication);
+ buffer.order(ByteOrder.BIG_ENDIAN);
+ return buffer.getInt(authentication.length - 4) & 0x007f_ffff;
+ }
+
+ private static @NonNull byte[] copyOf(@NonNull byte[] input) {
+ return Arrays.copyOf(input, input.length);
+ }
+
+ private static void validateLength(@NonNull byte[] input) throws DeviceTransferAuthenticationException {
+ if (input.length != DIGEST_LENGTH) {
+ throw new DeviceTransferAuthenticationException("invalid digest length");
+ }
+ }
+
+ /**
+ * Server side of authentication, responsible for verifying connecting
+ * devices commitment and generating a code.
+ */
+ @VisibleForTesting
+ static final class Server {
+ private final byte[] random;
+ private final byte[] certificate;
+ private final byte[] clientCommitment;
+ private byte[] clientRandom;
+
+ public Server(@NonNull byte[] certificate, @NonNull byte[] clientCommitment) throws DeviceTransferAuthenticationException {
+ validateLength(clientCommitment);
+
+ this.certificate = copyOf(certificate);
+ this.clientCommitment = copyOf(clientCommitment);
+
+ SecureRandom secureRandom = new SecureRandom();
+
+ this.random = new byte[DIGEST_LENGTH];
+ secureRandom.nextBytes(this.random);
+ }
+
+ public @NonNull byte[] getRandom() {
+ return copyOf(random);
+ }
+
+ public void setClientRandom(@NonNull byte[] clientRandom) throws DeviceTransferAuthenticationException {
+ validateLength(clientRandom);
+ this.clientRandom = copyOf(clientRandom);
+ }
+
+ public void verifyClientRandom() throws DeviceTransferAuthenticationException {
+ if (clientRandom == null) {
+ throw new DeviceTransferAuthenticationException("no client random set");
+ }
+
+ byte[] computedCommitment = getMac(copyOf(clientRandom)).doFinal(copyOf(certificate));
+ boolean commitmentsMatch = MessageDigest.isEqual(clientCommitment, computedCommitment);
+ if (!commitmentsMatch) {
+ throw new DeviceTransferAuthenticationException("commitments do not match, do not proceed");
+ }
+ }
+
+ public int computeShortAuthenticationCode() throws DeviceTransferAuthenticationException {
+ verifyClientRandom();
+ return DeviceTransferAuthentication.computeShortAuthenticationCode(copyOf(clientRandom), copyOf(random));
+ }
+ }
+
+ /**
+ * Client side of authentication, responsible for starting authentication with server.
+ */
+ @VisibleForTesting
+ static final class Client {
+
+ private final byte[] random;
+ private final byte[] commitment;
+ private byte[] serverRandom;
+
+ public Client(@NonNull byte[] certificate) throws DeviceTransferAuthenticationException {
+ SecureRandom secureRandom = new SecureRandom();
+
+ this.random = new byte[DIGEST_LENGTH];
+ secureRandom.nextBytes(this.random);
+
+ commitment = getMac(copyOf(this.random)).doFinal(copyOf(certificate));
+ }
+
+ public @NonNull byte[] getCommitment() {
+ return copyOf(commitment);
+ }
+
+ public @NonNull byte[] setServerRandomAndGetClientRandom(@NonNull byte[] serverRandom) throws DeviceTransferAuthenticationException {
+ validateLength(serverRandom);
+ this.serverRandom = copyOf(serverRandom);
+ return copyOf(random);
+ }
+
+ public int computeShortAuthenticationCode() throws DeviceTransferAuthenticationException {
+ if (serverRandom == null) {
+ throw new DeviceTransferAuthenticationException("no server random set");
+ }
+ return DeviceTransferAuthentication.computeShortAuthenticationCode(copyOf(random), copyOf(serverRandom));
+ }
+ }
+
+ public static final class DeviceTransferAuthenticationException extends Exception {
+ public DeviceTransferAuthenticationException(@NonNull String message) {
+ super(message);
+ }
+
+ public DeviceTransferAuthenticationException(@NonNull Throwable cause) {
+ super(cause);
+ }
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java
index e0b8548ee3..71f741f710 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java
@@ -12,13 +12,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.greenrobot.eventbus.EventBus;
-import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.Socket;
import java.util.concurrent.TimeUnit;
/**
@@ -29,8 +25,8 @@ import java.util.concurrent.TimeUnit;
* problems. It will also retry the task if an issue occurs while running it.
*
* The client is setup to retry indefinitely and will only bail on its own if it's
- * unable to start {@link WifiDirect}. A call to {@link #shutdown()} is required to
- * stop client from the "outside."
+ * unable to start {@link WifiDirect} or the network client connects and then completes
+ * or failed. A call to {@link #stop()} is required to stop client from the "outside."
*
* Summary of mitigations:
*
@@ -39,37 +35,43 @@ import java.util.concurrent.TimeUnit;
* - Retry connecting to the server until successful, disconnected from WiFi Direct network, or told to stop.
*
*/
-public final class DeviceTransferClient implements Handler.Callback {
+final class DeviceTransferClient implements Handler.Callback {
- private static final String TAG = Log.tag(DeviceTransferClient.class);
- private static final int START_CLIENT = 0;
- private static final int START_NETWORK_CLIENT = 1;
- private static final int NETWORK_DISCONNECTED = 2;
- private static final int CONNECT_TO_SERVICE = 3;
- private static final int RESTART_CLIENT = 4;
- private static final int START_IP_EXCHANGE = 5;
- private static final int IP_EXCHANGE_SUCCESS = 6;
+ private static final String TAG = Log.tag(DeviceTransferClient.class);
+
+ private static final int START_CLIENT = 0;
+ private static final int STOP_CLIENT = 1;
+ private static final int START_NETWORK_CLIENT = 2;
+ private static final int NETWORK_DISCONNECTED = 3;
+ private static final int CONNECT_TO_SERVICE = 4;
+ private static final int RESTART_CLIENT = 5;
+ private static final int START_IP_EXCHANGE = 6;
+ private static final int IP_EXCHANGE_SUCCESS = 7;
+ private static final int SET_VERIFIED = 8;
private final Context context;
- private final int port;
+ private int remotePort;
private HandlerThread commandAndControlThread;
private final Handler handler;
private final ClientTask clientTask;
private final ShutdownCallback shutdownCallback;
private WifiDirect wifiDirect;
- private ClientThread clientThread;
+ private NetworkClientThread clientThread;
private final Runnable autoRestart;
private IpExchange.IpExchangeThread ipExchangeThread;
- private static void update(@NonNull TransferMode transferMode) {
- EventBus.getDefault().postSticky(transferMode);
+ private static void update(@NonNull TransferStatus transferStatus) {
+ Log.d(TAG, "transferStatus: " + transferStatus.getTransferMode().name());
+ EventBus.getDefault().postSticky(transferStatus);
}
@AnyThread
- public DeviceTransferClient(@NonNull Context context, @NonNull ClientTask clientTask, int port, @Nullable ShutdownCallback shutdownCallback) {
+ public DeviceTransferClient(@NonNull Context context,
+ @NonNull ClientTask clientTask,
+ @Nullable ShutdownCallback shutdownCallback)
+ {
this.context = context;
this.clientTask = clientTask;
- this.port = port;
this.shutdownCallback = shutdownCallback;
this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("client-cnc");
this.handler = new Handler(commandAndControlThread.getLooper(), this);
@@ -80,12 +82,28 @@ public final class DeviceTransferClient implements Handler.Callback {
}
@AnyThread
- public void start() {
- handler.sendMessage(handler.obtainMessage(START_CLIENT));
+ public synchronized void start() {
+ if (commandAndControlThread != null) {
+ update(TransferStatus.ready());
+ handler.sendEmptyMessage(START_CLIENT);
+ }
}
@AnyThread
- public synchronized void shutdown() {
+ public synchronized void stop() {
+ if (commandAndControlThread != null) {
+ handler.sendEmptyMessage(STOP_CLIENT);
+ }
+ }
+
+ @AnyThread
+ public synchronized void setVerified(boolean isVerified) {
+ if (commandAndControlThread != null) {
+ handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified));
+ }
+ }
+
+ private synchronized void shutdown() {
stopIpExchange();
stopClient();
stopWifiDirect();
@@ -96,14 +114,27 @@ public final class DeviceTransferClient implements Handler.Callback {
commandAndControlThread.interrupt();
commandAndControlThread = null;
}
+
+ update(TransferStatus.shutdown());
+ }
+
+ private void internalShutdown() {
+ shutdown();
+ if (shutdownCallback != null) {
+ shutdownCallback.shutdown();
+ }
}
@Override
public boolean handleMessage(@NonNull Message message) {
+ Log.d(TAG, "Handle message: " + message.what);
switch (message.what) {
case START_CLIENT:
startWifiDirect();
break;
+ case STOP_CLIENT:
+ shutdown();
+ break;
case START_NETWORK_CLIENT:
startClient((String) message.obj);
break;
@@ -111,7 +142,8 @@ public final class DeviceTransferClient implements Handler.Callback {
stopClient();
break;
case CONNECT_TO_SERVICE:
- connectToService((String) message.obj);
+ stopServiceDiscovery();
+ connectToService((String) message.obj, message.arg1);
break;
case RESTART_CLIENT:
stopClient();
@@ -124,12 +156,26 @@ public final class DeviceTransferClient implements Handler.Callback {
case IP_EXCHANGE_SUCCESS:
ipExchangeSuccessful((String) message.obj);
break;
- default:
- shutdown();
- if (shutdownCallback != null) {
- shutdownCallback.shutdown();
+ case SET_VERIFIED:
+ if (clientThread != null) {
+ clientThread.setVerified((Boolean) message.obj);
}
- throw new AssertionError();
+ break;
+ case NetworkClientThread.NETWORK_CLIENT_SSL_ESTABLISHED:
+ update(TransferStatus.verificationRequired((Integer) message.obj));
+ break;
+ case NetworkClientThread.NETWORK_CLIENT_CONNECTED:
+ update(TransferStatus.serviceConnected());
+ break;
+ case NetworkClientThread.NETWORK_CLIENT_DISCONNECTED:
+ update(TransferStatus.networkConnected());
+ break;
+ case NetworkClientThread.NETWORK_CLIENT_STOPPED:
+ internalShutdown();
+ break;
+ default:
+ internalShutdown();
+ throw new AssertionError("Unknown message: " + message.what);
}
return false;
}
@@ -140,25 +186,41 @@ public final class DeviceTransferClient implements Handler.Callback {
return;
}
- update(TransferMode.STARTING_UP);
+ update(TransferStatus.startingUp());
try {
wifiDirect = new WifiDirect(context);
wifiDirect.initialize(new WifiDirectListener());
wifiDirect.discoverService();
Log.i(TAG, "Started service discovery, searching for service...");
- update(TransferMode.DISCOVERY);
+ update(TransferStatus.discovery());
handler.postDelayed(autoRestart, TimeUnit.SECONDS.toMillis(15));
} catch (WifiDirectUnavailableException e) {
Log.e(TAG, e);
- shutdown();
- update(TransferMode.FAILED);
- if (shutdownCallback != null) {
- shutdownCallback.shutdown();
+ internalShutdown();
+ if (e.getReason() == WifiDirectUnavailableException.Reason.CHANNEL_INITIALIZATION ||
+ e.getReason() == WifiDirectUnavailableException.Reason.WIFI_P2P_MANAGER) {
+ update(TransferStatus.unavailable());
+ } else {
+ update(TransferStatus.failed());
}
}
}
+ private void stopServiceDiscovery() {
+ if (wifiDirect == null) {
+ return;
+ }
+
+ try {
+ Log.i(TAG, "Stopping service discovery");
+ wifiDirect.stopServiceDiscovery();
+ } catch (WifiDirectUnavailableException e) {
+ internalShutdown();
+ update(TransferStatus.failed());
+ }
+ }
+
private void stopWifiDirect() {
handler.removeCallbacks(autoRestart);
@@ -166,7 +228,6 @@ public final class DeviceTransferClient implements Handler.Callback {
Log.i(TAG, "Shutting down WiFi Direct");
wifiDirect.shutdown();
wifiDirect = null;
- update(TransferMode.READY);
}
}
@@ -177,7 +238,11 @@ public final class DeviceTransferClient implements Handler.Callback {
}
Log.i(TAG, "Connection established, spinning up network client.");
- clientThread = new ClientThread(context, clientTask, serverHostAddress, port);
+ clientThread = new NetworkClientThread(context,
+ clientTask,
+ serverHostAddress,
+ remotePort,
+ handler);
clientThread.start();
}
@@ -185,11 +250,16 @@ public final class DeviceTransferClient implements Handler.Callback {
if (clientThread != null) {
Log.i(TAG, "Shutting down ClientThread");
clientThread.shutdown();
+ try {
+ clientThread.join(TimeUnit.SECONDS.toMillis(1));
+ } catch (InterruptedException e) {
+ Log.i(TAG, "Server thread took too long to shutdown", e);
+ }
clientThread = null;
}
}
- private void connectToService(@NonNull String deviceAddress) {
+ private void connectToService(@NonNull String deviceAddress, int port) {
if (wifiDirect == null) {
Log.w(TAG, "WifiDirect is not initialized, we shouldn't be here.");
return;
@@ -201,11 +271,17 @@ public final class DeviceTransferClient implements Handler.Callback {
while ((tries--) > 0) {
try {
wifiDirect.connect(deviceAddress);
- update(TransferMode.NETWORK_CONNECTED);
+ update(TransferStatus.networkConnected());
+ remotePort = port;
return;
} catch (WifiDirectUnavailableException e) {
Log.w(TAG, "Unable to connect, tries: " + tries);
- ThreadUtil.sleep(TimeUnit.SECONDS.toMillis(2));
+ try {
+ Thread.sleep(TimeUnit.SECONDS.toMillis(2));
+ } catch (InterruptedException ignored) {
+ Log.i(TAG, "Interrupted while connecting to service, bail now!");
+ return;
+ }
}
}
@@ -213,12 +289,17 @@ public final class DeviceTransferClient implements Handler.Callback {
}
private void startIpExchange(@NonNull String groupOwnerHostAddress) {
- ipExchangeThread = IpExchange.getIp(groupOwnerHostAddress, port, handler, IP_EXCHANGE_SUCCESS);
+ ipExchangeThread = IpExchange.getIp(groupOwnerHostAddress, remotePort, handler, IP_EXCHANGE_SUCCESS);
}
private void stopIpExchange() {
if (ipExchangeThread != null) {
ipExchangeThread.shutdown();
+ try {
+ ipExchangeThread.join(TimeUnit.SECONDS.toMillis(1));
+ } catch (InterruptedException e) {
+ Log.i(TAG, "IP Exchange thread took too long to shutdown", e);
+ }
ipExchangeThread = null;
}
}
@@ -228,90 +309,11 @@ public final class DeviceTransferClient implements Handler.Callback {
handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, host));
}
- private static class ClientThread extends Thread {
-
- private volatile Socket client;
- private volatile boolean isRunning;
-
- private final Context context;
- private final ClientTask clientTask;
- private final String serverHostAddress;
- private final int port;
-
- public ClientThread(@NonNull Context context,
- @NonNull ClientTask clientTask,
- @NonNull String serverHostAddress,
- int port)
- {
- this.context = context;
- this.clientTask = clientTask;
- this.serverHostAddress = serverHostAddress;
- this.port = port;
- }
-
- @Override
- public void run() {
- Log.i(TAG, "Client thread running");
- isRunning = true;
-
- while (shouldKeepRunning()) {
- Log.i(TAG, "Attempting to connect to server...");
-
- try {
- client = new Socket();
- try {
- client.bind(null);
- client.connect(new InetSocketAddress(serverHostAddress, port), 10000);
- DeviceTransferClient.update(TransferMode.SERVICE_CONNECTED);
-
- clientTask.run(context, client.getOutputStream());
-
- Log.i(TAG, "Done!!");
- isRunning = false;
- } catch (IOException e) {
- Log.w(TAG, "Error connecting to server", e);
- }
- } catch (Exception e) {
- Log.w(TAG, e);
- } finally {
- if (client != null && !client.isClosed()) {
- try {
- client.close();
- } catch (IOException ignored) {}
- }
- DeviceTransferClient.update(TransferMode.NETWORK_CONNECTED);
- }
-
- if (shouldKeepRunning()) {
- ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3));
- }
- }
-
- Log.i(TAG, "Client exiting");
- }
-
- public void shutdown() {
- isRunning = false;
- try {
- if (client != null) {
- client.close();
- }
- } catch (IOException e) {
- Log.w(TAG, "Error shutting down client socket", e);
- }
- interrupt();
- }
-
- private boolean shouldKeepRunning() {
- return !isInterrupted() && isRunning;
- }
- }
-
public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener {
@Override
- public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice) {
- handler.sendMessage(handler.obtainMessage(CONNECT_TO_SERVICE, serviceDevice.deviceAddress));
+ public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo) {
+ handler.sendMessage(handler.obtainMessage(CONNECT_TO_SERVICE, Integer.parseInt(extraInfo), 0, serviceDevice.deviceAddress));
}
@Override
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java
index 5e83fdc071..6ec376b695 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java
@@ -12,13 +12,10 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.greenrobot.eventbus.EventBus;
-import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
+import org.signal.devicetransfer.SelfSignedIdentity.SelfSignedKeys;
-import java.io.IOException;
-import java.net.ServerSocket;
-import java.net.Socket;
import java.util.concurrent.TimeUnit;
/**
@@ -33,46 +30,66 @@ import java.util.concurrent.TimeUnit;
* Testing found that restarting the client worked better than restarting the server when having WiFi
* Direct setup issues.
*/
-public final class DeviceTransferServer implements Handler.Callback {
+final class DeviceTransferServer implements Handler.Callback {
- private static final String TAG = Log.tag(DeviceTransferServer.class);
- private static final int START_SERVER = 0;
- private static final int START_NETWORK_SERVER = 1;
- private static final int NETWORK_DISCONNECTED = 2;
- private static final int START_IP_EXCHANGE = 3;
- private static final int IP_EXCHANGE_SUCCESS = 4;
+ private static final String TAG = Log.tag(DeviceTransferServer.class);
- private ServerThread serverThread;
+ private static final int START_SERVER = 0;
+ private static final int STOP_SERVER = 1;
+ private static final int START_IP_EXCHANGE = 2;
+ private static final int IP_EXCHANGE_SUCCESS = 3;
+ private static final int NETWORK_FAILURE = 4;
+ private static final int SET_VERIFIED = 5;
+
+ private NetworkServerThread serverThread;
private HandlerThread commandAndControlThread;
private final Handler handler;
private WifiDirect wifiDirect;
private final Context context;
private final ServerTask serverTask;
- private final int port;
private final ShutdownCallback shutdownCallback;
private IpExchange.IpExchangeThread ipExchangeThread;
- private static void update(@NonNull TransferMode transferMode) {
- EventBus.getDefault().postSticky(transferMode);
+ private static void update(@NonNull TransferStatus transferStatus) {
+ Log.d(TAG, "transferStatus: " + transferStatus.getTransferMode().name());
+ EventBus.getDefault().postSticky(transferStatus);
}
@AnyThread
- public DeviceTransferServer(@NonNull Context context, @NonNull ServerTask serverTask, int port, @Nullable ShutdownCallback shutdownCallback) {
+ public DeviceTransferServer(@NonNull Context context,
+ @NonNull ServerTask serverTask,
+ @Nullable ShutdownCallback shutdownCallback)
+ {
this.context = context;
this.serverTask = serverTask;
- this.port = port;
this.shutdownCallback = shutdownCallback;
this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("server-cnc");
this.handler = new Handler(commandAndControlThread.getLooper(), this);
}
@AnyThread
- public void start() {
- handler.sendMessage(handler.obtainMessage(START_SERVER));
+ public synchronized void start() {
+ if (commandAndControlThread != null) {
+ update(TransferStatus.ready());
+ handler.sendEmptyMessage(START_SERVER);
+ }
}
@AnyThread
- public synchronized void shutdown() {
+ public synchronized void stop() {
+ if (commandAndControlThread != null) {
+ handler.sendEmptyMessage(STOP_SERVER);
+ }
+ }
+
+ @AnyThread
+ public synchronized void setVerified(boolean isVerified) {
+ if (commandAndControlThread != null) {
+ handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified));
+ }
+ }
+
+ private synchronized void shutdown() {
stopIpExchange();
stopServer();
stopWifiDirect();
@@ -83,19 +100,26 @@ public final class DeviceTransferServer implements Handler.Callback {
commandAndControlThread.interrupt();
commandAndControlThread = null;
}
+
+ update(TransferStatus.shutdown());
+ }
+
+ private void internalShutdown() {
+ shutdown();
+ if (shutdownCallback != null) {
+ shutdownCallback.shutdown();
+ }
}
@Override
public boolean handleMessage(@NonNull Message message) {
+ Log.d(TAG, "Handle message: " + message.what);
switch (message.what) {
case START_SERVER:
- startWifiDirect();
+ startNetworkServer();
break;
- case START_NETWORK_SERVER:
- startServer();
- break;
- case NETWORK_DISCONNECTED:
- stopServer();
+ case STOP_SERVER:
+ shutdown();
break;
case START_IP_EXCHANGE:
startIpExchange((String) message.obj);
@@ -103,188 +127,153 @@ public final class DeviceTransferServer implements Handler.Callback {
case IP_EXCHANGE_SUCCESS:
ipExchangeSuccessful();
break;
- default:
- shutdown();
- if (shutdownCallback != null) {
- shutdownCallback.shutdown();
+ case SET_VERIFIED:
+ if (serverThread != null) {
+ serverThread.setVerified((Boolean) message.obj);
}
- throw new AssertionError();
+ break;
+ case NetworkServerThread.NETWORK_SERVER_STARTED:
+ startWifiDirect(message.arg1);
+ break;
+ case NetworkServerThread.NETWORK_SERVER_STOPPED:
+ internalShutdown();
+ break;
+ case NetworkServerThread.NETWORK_CLIENT_CONNECTED:
+ stopDiscoveryService();
+ update(TransferStatus.serviceConnected());
+ break;
+ case NetworkServerThread.NETWORK_CLIENT_DISCONNECTED:
+ update(TransferStatus.networkConnected());
+ break;
+ case NetworkServerThread.NETWORK_CLIENT_SSL_ESTABLISHED:
+ update(TransferStatus.verificationRequired((Integer) message.obj));
+ break;
+ default:
+ internalShutdown();
+ throw new AssertionError("Unknown message: " + message.what);
}
return false;
}
- private void startWifiDirect() {
+ private void startWifiDirect(int port) {
if (wifiDirect != null) {
Log.e(TAG, "Server already started");
return;
}
- update(TransferMode.STARTING_UP);
-
try {
wifiDirect = new WifiDirect(context);
wifiDirect.initialize(new WifiDirectListener());
- wifiDirect.startDiscoveryService();
+ wifiDirect.startDiscoveryService(String.valueOf(port));
Log.i(TAG, "Started discovery service, waiting for connections...");
- update(TransferMode.DISCOVERY);
+ update(TransferStatus.discovery());
} catch (WifiDirectUnavailableException e) {
Log.e(TAG, e);
- shutdown();
- update(TransferMode.FAILED);
- if (shutdownCallback != null) {
- shutdownCallback.shutdown();
+ internalShutdown();
+ if (e.getReason() == WifiDirectUnavailableException.Reason.CHANNEL_INITIALIZATION ||
+ e.getReason() == WifiDirectUnavailableException.Reason.WIFI_P2P_MANAGER) {
+ update(TransferStatus.unavailable());
+ } else {
+ update(TransferStatus.failed());
}
}
}
+ private void stopDiscoveryService() {
+ if (wifiDirect == null) {
+ return;
+ }
+
+ try {
+ Log.i(TAG, "Stopping discovery service");
+ wifiDirect.stopDiscoveryService();
+ } catch (WifiDirectUnavailableException e) {
+ internalShutdown();
+ update(TransferStatus.failed());
+ }
+ }
+
private void stopWifiDirect() {
if (wifiDirect != null) {
Log.i(TAG, "Shutting down WiFi Direct");
wifiDirect.shutdown();
wifiDirect = null;
- update(TransferMode.READY);
}
}
- private void startServer() {
+ private void startNetworkServer() {
if (serverThread != null) {
Log.i(TAG, "Server already running");
return;
}
- Log.i(TAG, "Connection established, spinning up network server.");
- serverThread = new ServerThread(context, serverTask, port);
- serverThread.start();
-
- update(TransferMode.NETWORK_CONNECTED);
+ try {
+ update(TransferStatus.startingUp());
+ SelfSignedKeys keys = SelfSignedIdentity.create();
+ Log.i(TAG, "Spinning up network server.");
+ serverThread = new NetworkServerThread(context, serverTask, keys, handler);
+ serverThread.start();
+ } catch (KeyGenerationFailedException e) {
+ Log.w(TAG, "Error generating keys", e);
+ internalShutdown();
+ update(TransferStatus.failed());
+ }
}
private void stopServer() {
if (serverThread != null) {
Log.i(TAG, "Shutting down ServerThread");
serverThread.shutdown();
+ try {
+ serverThread.join(TimeUnit.SECONDS.toMillis(1));
+ } catch (InterruptedException e) {
+ Log.i(TAG, "Server thread took too long to shutdown", e);
+ }
serverThread = null;
}
}
private void startIpExchange(@NonNull String groupOwnerHostAddress) {
- ipExchangeThread = IpExchange.giveIp(groupOwnerHostAddress, port, handler, IP_EXCHANGE_SUCCESS);
+ ipExchangeThread = IpExchange.giveIp(groupOwnerHostAddress, serverThread.getLocalPort(), handler, IP_EXCHANGE_SUCCESS);
}
private void stopIpExchange() {
if (ipExchangeThread != null) {
ipExchangeThread.shutdown();
+ try {
+ ipExchangeThread.join(TimeUnit.SECONDS.toMillis(1));
+ } catch (InterruptedException e) {
+ Log.i(TAG, "IP Exchange thread took too long to shutdown", e);
+ }
ipExchangeThread = null;
}
}
private void ipExchangeSuccessful() {
stopIpExchange();
- handler.sendEmptyMessage(START_NETWORK_SERVER);
- }
-
- public static class ServerThread extends Thread {
-
- private final Context context;
- private final ServerTask serverTask;
- private final int port;
- private volatile ServerSocket serverSocket;
- private volatile boolean isRunning;
-
- public ServerThread(@NonNull Context context, @NonNull ServerTask serverTask, int port) {
- this.context = context;
- this.serverTask = serverTask;
- this.port = port;
- }
-
- @Override
- public void run() {
- Log.i(TAG, "Server thread running");
- isRunning = true;
-
- while (shouldKeepRunning()) {
- Log.i(TAG, "Starting up server socket...");
- try {
- serverSocket = new ServerSocket(port);
- while (shouldKeepRunning() && !serverSocket.isClosed()) {
- Log.i(TAG, "Waiting for client socket accept...");
- try {
- handleClient(serverSocket.accept());
- } catch (IOException e) {
- if (isRunning) {
- Log.i(TAG, "Error connecting with client or server socket closed.", e);
- } else {
- Log.i(TAG, "Server shutting down...");
- }
- } finally {
- update(TransferMode.NETWORK_CONNECTED);
- }
- }
- } catch (Exception e) {
- Log.w(TAG, e);
- } finally {
- if (serverSocket != null && !serverSocket.isClosed()) {
- try {
- serverSocket.close();
- } catch (IOException ignored) {}
- }
- update(TransferMode.NETWORK_CONNECTED);
- }
-
- if (shouldKeepRunning()) {
- ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3));
- }
- }
-
- Log.i(TAG, "Server exiting");
- }
-
- public void shutdown() {
- isRunning = false;
- try {
- serverSocket.close();
- } catch (IOException e) {
- Log.w(TAG, "Error shutting down server socket", e);
- }
- interrupt();
- }
-
- private void handleClient(@NonNull Socket clientSocket) throws IOException {
- update(TransferMode.SERVICE_CONNECTED);
- serverTask.run(context, clientSocket.getInputStream());
- clientSocket.close();
- }
-
- private boolean shouldKeepRunning() {
- return !isInterrupted() && isRunning;
- }
}
public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener {
@Override
public void onNetworkConnected(@NonNull WifiP2pInfo info) {
- if (info.isGroupOwner) {
- handler.sendEmptyMessage(START_NETWORK_SERVER);
- } else {
+ if (!info.isGroupOwner) {
handler.sendMessage(handler.obtainMessage(START_IP_EXCHANGE, info.groupOwnerAddress.getHostAddress()));
}
}
@Override
- public void onNetworkDisconnected() {
- handler.sendEmptyMessage(NETWORK_DISCONNECTED);
- }
+ public void onNetworkDisconnected() { }
@Override
public void onNetworkFailure() {
- handler.sendEmptyMessage(NETWORK_DISCONNECTED);
+ handler.sendEmptyMessage(NETWORK_FAILURE);
}
@Override
public void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice) { }
@Override
- public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice) { }
+ public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo) { }
}
}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java
index 0cf16f5cb6..ce79769e1e 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java
@@ -3,7 +3,6 @@ package org.signal.devicetransfer;
import android.os.Handler;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
@@ -22,7 +21,7 @@ import java.util.concurrent.TimeUnit;
* When this occurs, {@link #giveIp(String, int, Handler, int)} and {@link #getIp(String, int, Handler, int)} allow
* the two to connect briefly and use the connected socket to determine the host address of the other.
*/
-public final class IpExchange {
+final class IpExchange {
private IpExchange() { }
@@ -66,7 +65,7 @@ public final class IpExchange {
isRunning = true;
while (shouldKeepRunning()) {
- Log.i(TAG, "Attempting to connect to server...");
+ Log.i(TAG, "Attempting to startup networking...");
try {
if (needsIp) {
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/KeyGenerationFailedException.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/KeyGenerationFailedException.java
new file mode 100644
index 0000000000..ac45f1ad09
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/KeyGenerationFailedException.java
@@ -0,0 +1,12 @@
+package org.signal.devicetransfer;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Thrown when there's an issue generating the self-signed certificates for TLS.
+ */
+final class KeyGenerationFailedException extends Throwable {
+ public KeyGenerationFailedException(@NonNull Exception e) {
+ super(e);
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java
new file mode 100644
index 0000000000..25e1b1933c
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java
@@ -0,0 +1,169 @@
+package org.signal.devicetransfer;
+
+import android.content.Context;
+import android.os.Handler;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.StreamUtil;
+import org.signal.core.util.ThreadUtil;
+import org.signal.core.util.logging.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLSocket;
+
+import static org.signal.devicetransfer.DeviceTransferAuthentication.DIGEST_LENGTH;
+
+/**
+ * Performs the networking setup/tear down for the client. This includes
+ * connecting to the server, performing the TLS/SAS verification, running an
+ * arbitrarily provided {@link ClientTask}, and then cleaning up.
+ */
+final class NetworkClientThread extends Thread {
+
+ private static final String TAG = Log.tag(NetworkClientThread.class);
+
+ public static final int NETWORK_CLIENT_CONNECTED = 1001;
+ public static final int NETWORK_CLIENT_DISCONNECTED = 1002;
+ public static final int NETWORK_CLIENT_SSL_ESTABLISHED = 1003;
+ public static final int NETWORK_CLIENT_STOPPED = 1004;
+
+ private volatile SSLSocket client;
+ private volatile boolean isRunning;
+ private volatile Boolean isVerified;
+
+ private final Context context;
+ private final ClientTask clientTask;
+ private final String serverHostAddress;
+ private final int port;
+ private final Handler handler;
+ private final Object verificationLock;
+ private boolean success;
+
+ public NetworkClientThread(@NonNull Context context,
+ @NonNull ClientTask clientTask,
+ @NonNull String serverHostAddress,
+ int port,
+ @NonNull Handler handler)
+ {
+ this.context = context;
+ this.clientTask = clientTask;
+ this.serverHostAddress = serverHostAddress;
+ this.port = port;
+ this.handler = handler;
+ this.verificationLock = new Object();
+ }
+
+ @Override
+ public void run() {
+ Log.i(TAG, "Client thread running");
+ isRunning = true;
+
+ int validClientAttemptsRemaining = 3;
+ while (shouldKeepRunning()) {
+ Log.i(TAG, "Attempting to connect to server... tries: " + validClientAttemptsRemaining);
+
+ try {
+ SelfSignedIdentity.ApprovingTrustManager trustManager = new SelfSignedIdentity.ApprovingTrustManager();
+ client = (SSLSocket) SelfSignedIdentity.getApprovingSocketFactory(trustManager).createSocket();
+ try {
+ client.bind(null);
+ client.connect(new InetSocketAddress(serverHostAddress, port), 10000);
+ client.startHandshake();
+
+ X509Certificate x509 = trustManager.getX509Certificate();
+ if (x509 == null) {
+ isRunning = false;
+ throw new SSLHandshakeException("no x509 after handshake");
+ }
+
+ InputStream inputStream = client.getInputStream();
+ OutputStream outputStream = client.getOutputStream();
+ int authenticationCode = DeviceTransferAuthentication.generateClientAuthenticationCode(x509.getEncoded(), inputStream, outputStream);
+
+ handler.sendMessage(handler.obtainMessage(NETWORK_CLIENT_SSL_ESTABLISHED, authenticationCode));
+
+ Log.i(TAG, "Waiting for user to verify sas");
+ awaitAuthenticationCodeVerification();
+ Log.d(TAG, "Waiting for server to tell us they also verified");
+ //noinspection ResultOfMethodCallIgnored
+ inputStream.read();
+
+ handler.sendEmptyMessage(NETWORK_CLIENT_CONNECTED);
+ clientTask.run(context, outputStream);
+ outputStream.flush();
+ client.shutdownOutput();
+
+ Log.d(TAG, "Waiting for server to tell us they got everything");
+ //noinspection ResultOfMethodCallIgnored
+ inputStream.read();
+ success = true;
+ isRunning = false;
+ } catch (IOException e) {
+ Log.w(TAG, "Error connecting to server", e);
+ validClientAttemptsRemaining--;
+ isRunning = validClientAttemptsRemaining > 0;
+ }
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ isRunning = false;
+ } finally {
+ StreamUtil.close(client);
+ handler.sendEmptyMessage(NETWORK_CLIENT_DISCONNECTED);
+ }
+
+ if (shouldKeepRunning()) {
+ ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3));
+ }
+ }
+
+ Log.i(TAG, "Client exiting");
+ if (success) {
+ clientTask.success();
+ }
+ handler.sendEmptyMessage(NETWORK_CLIENT_STOPPED);
+ }
+
+ private void awaitAuthenticationCodeVerification() throws DeviceTransferAuthentication.DeviceTransferAuthenticationException {
+ synchronized (verificationLock) {
+ try {
+ while (isVerified == null) {
+ verificationLock.wait();
+ }
+ if (!isVerified) {
+ throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("User verification failed");
+ }
+ } catch (InterruptedException e) {
+ throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
+ }
+ }
+ }
+
+ @AnyThread
+ public void setVerified(boolean isVerified) {
+ this.isVerified = isVerified;
+ synchronized (verificationLock) {
+ verificationLock.notify();
+ }
+ }
+
+ @AnyThread
+ public void shutdown() {
+ isRunning = false;
+ StreamUtil.close(client);
+ interrupt();
+ }
+
+ private boolean shouldKeepRunning() {
+ return !isInterrupted() && isRunning;
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java
new file mode 100644
index 0000000000..f0953888c6
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java
@@ -0,0 +1,146 @@
+package org.signal.devicetransfer;
+
+import android.content.Context;
+import android.os.Handler;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.StreamUtil;
+import org.signal.core.util.logging.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.Arrays;
+
+/**
+ * Performs the networking setup/tear down for the server. This includes
+ * connecting to the client, generating TLS keys, performing the TLS/SAS verification,
+ * running an arbitrarily provided {@link ServerTask}, and then cleaning up.
+ */
+final class NetworkServerThread extends Thread {
+
+ private static final String TAG = Log.tag(NetworkServerThread.class);
+
+ public static final int NETWORK_SERVER_STARTED = 1001;
+ public static final int NETWORK_SERVER_STOPPED = 1002;
+ public static final int NETWORK_CLIENT_CONNECTED = 1003;
+ public static final int NETWORK_CLIENT_DISCONNECTED = 1004;
+ public static final int NETWORK_CLIENT_SSL_ESTABLISHED = 1005;
+
+ private volatile ServerSocket serverSocket;
+ private volatile boolean isRunning;
+ private volatile Boolean isVerified;
+
+ private final Context context;
+ private final ServerTask serverTask;
+ private final SelfSignedIdentity.SelfSignedKeys keys;
+ private final Handler handler;
+ private final Object verificationLock;
+
+ public NetworkServerThread(@NonNull Context context,
+ @NonNull ServerTask serverTask,
+ @NonNull SelfSignedIdentity.SelfSignedKeys keys,
+ @NonNull Handler handler)
+ {
+ this.context = context;
+ this.serverTask = serverTask;
+ this.keys = keys;
+ this.handler = handler;
+ this.verificationLock = new Object();
+ }
+
+ @Override
+ public void run() {
+ Log.i(TAG, "Server thread running");
+ isRunning = true;
+
+ Log.i(TAG, "Starting up server socket...");
+ try {
+ serverSocket = SelfSignedIdentity.getServerSocketFactory(keys).createServerSocket(0);
+ handler.sendMessage(handler.obtainMessage(NETWORK_SERVER_STARTED, serverSocket.getLocalPort(), 0));
+ while (shouldKeepRunning() && !serverSocket.isClosed()) {
+ Log.i(TAG, "Waiting for client socket accept...");
+ try (Socket clientSocket = serverSocket.accept()) {
+ InputStream inputStream = clientSocket.getInputStream();
+ OutputStream outputStream = clientSocket.getOutputStream();
+ int authenticationCode = DeviceTransferAuthentication.generateServerAuthenticationCode(keys.getX509Encoded(), inputStream, outputStream);
+
+ handler.sendMessage(handler.obtainMessage(NETWORK_CLIENT_SSL_ESTABLISHED, authenticationCode));
+
+ Log.i(TAG, "Waiting for user to verify sas");
+ awaitAuthenticationCodeVerification();
+
+ handler.sendEmptyMessage(NETWORK_CLIENT_CONNECTED);
+ outputStream.write(0x43);
+ outputStream.flush();
+ serverTask.run(context, inputStream);
+ outputStream.write(0x53);
+ outputStream.flush();
+ } catch (IOException e) {
+ if (isRunning) {
+ Log.i(TAG, "Error connecting with client or server socket closed.", e);
+ } else {
+ Log.i(TAG, "Server shutting down...");
+ }
+ } finally {
+ handler.sendEmptyMessage(NETWORK_CLIENT_DISCONNECTED);
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ } finally {
+ StreamUtil.close(serverSocket);
+ }
+
+ Log.i(TAG, "Server exiting");
+ isRunning = false;
+ handler.sendEmptyMessage(NETWORK_SERVER_STOPPED);
+ }
+
+ private void awaitAuthenticationCodeVerification() throws DeviceTransferAuthentication.DeviceTransferAuthenticationException {
+ synchronized (verificationLock) {
+ try {
+ while (isVerified == null) {
+ verificationLock.wait();
+ }
+ if (!isVerified) {
+ throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("User verification failed");
+ }
+ } catch (InterruptedException e) {
+ throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
+ }
+ }
+ }
+
+ private boolean shouldKeepRunning() {
+ return !isInterrupted() && isRunning;
+ }
+
+ @AnyThread
+ public int getLocalPort() {
+ ServerSocket localServerSocket = serverSocket;
+ if (localServerSocket != null) {
+ return localServerSocket.getLocalPort();
+ }
+ return 0;
+ }
+
+ @AnyThread
+ public void setVerified(boolean isVerified) {
+ this.isVerified = isVerified;
+ synchronized (verificationLock) {
+ verificationLock.notify();
+ }
+ }
+
+ @AnyThread
+ public void shutdown() {
+ isRunning = false;
+ StreamUtil.close(serverSocket);
+ interrupt();
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/SelfSignedIdentity.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/SelfSignedIdentity.java
new file mode 100644
index 0000000000..3b60cfd953
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/SelfSignedIdentity.java
@@ -0,0 +1,170 @@
+package org.signal.devicetransfer;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.spongycastle.asn1.x500.X500Name;
+import org.spongycastle.asn1.x500.X500NameBuilder;
+import org.spongycastle.asn1.x500.style.BCStyle;
+import org.spongycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.spongycastle.cert.X509CertificateHolder;
+import org.spongycastle.cert.X509v3CertificateBuilder;
+import org.spongycastle.jce.provider.BouncyCastleProvider;
+import org.spongycastle.operator.ContentSigner;
+import org.spongycastle.operator.OperatorCreationException;
+import org.spongycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocketFactory;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+/**
+ * Generate and configure use of self-signed x509 and private key for establishing a TLS connection.
+ */
+final class SelfSignedIdentity {
+
+ private static final String KEY_GENERATION_ALGORITHM = "RSA";
+ private static final int KEY_SIZE = 4096;
+ private static final String SSL_CONTEXT_PROTOCOL = "TLS";
+ private static final String CERTIFICATE_TYPE = "X509";
+ private static final String KEYSTORE_TYPE = "BKS";
+ private static final String SIGNATURE_ALGORITHM = "SHA256WithRSAEncryption";
+
+ private SelfSignedIdentity() { }
+
+ public static @NonNull SelfSignedKeys create() throws KeyGenerationFailedException {
+ try {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_GENERATION_ALGORITHM);
+ keyPairGenerator.initialize(KEY_SIZE);
+
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ X509CertificateHolder x509 = createX509(keyPair);
+
+ return new SelfSignedKeys(x509.getEncoded(), keyPair.getPrivate());
+ } catch (GeneralSecurityException | OperatorCreationException | IOException e) {
+ throw new KeyGenerationFailedException(e);
+ }
+ }
+
+ public static @NonNull SSLServerSocketFactory getServerSocketFactory(@NonNull SelfSignedKeys keys)
+ throws GeneralSecurityException, IOException
+ {
+ Certificate certificate = CertificateFactory.getInstance(CERTIFICATE_TYPE)
+ .generateCertificate(new ByteArrayInputStream(keys.getX509Encoded()));
+
+ KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
+ keyStore.load(null);
+ keyStore.setKeyEntry("client", keys.getPrivateKey(), null, new Certificate[]{certificate});
+
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(keyStore, null);
+
+ SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL);
+ sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
+
+ return sslContext.getServerSocketFactory();
+ }
+
+ public static @NonNull SSLSocketFactory getApprovingSocketFactory(@NonNull ApprovingTrustManager trustManager)
+ throws GeneralSecurityException
+ {
+ SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL);
+ sslContext.init(null, new TrustManager[]{trustManager}, new SecureRandom());
+ return sslContext.getSocketFactory();
+ }
+
+ private static @NonNull X509CertificateHolder createX509(@NonNull KeyPair keyPair) throws OperatorCreationException {
+ Date startDate = new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000);
+ Date endDate = new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000);
+
+ X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
+ nameBuilder.addRDN(BCStyle.C, "United States");
+ nameBuilder.addRDN(BCStyle.ST, "California");
+ nameBuilder.addRDN(BCStyle.L, "San Francisco");
+ nameBuilder.addRDN(BCStyle.O, "Signal Foundation");
+ nameBuilder.addRDN(BCStyle.CN, "SignalTransfer");
+
+ X500Name x500Name = nameBuilder.build();
+ BigInteger serialNumber = BigInteger.valueOf(new SecureRandom().nextLong()).abs();
+ SubjectPublicKeyInfo subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());
+
+ X509v3CertificateBuilder certificateBuilder = new X509v3CertificateBuilder(x500Name,
+ serialNumber,
+ startDate,
+ endDate,
+ x500Name,
+ subjectPublicKeyInfo);
+
+ Security.addProvider(new BouncyCastleProvider());
+ ContentSigner signer = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BouncyCastleProvider.PROVIDER_NAME)
+ .build(keyPair.getPrivate());
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
+
+ return certificateBuilder.build(signer);
+ }
+
+ static final class SelfSignedKeys {
+ private final byte[] x509Encoded;
+ private final PrivateKey privateKey;
+
+ public SelfSignedKeys(@NonNull byte[] x509Encoded, @NonNull PrivateKey privateKey) {
+ this.x509Encoded = x509Encoded;
+ this.privateKey = privateKey;
+ }
+
+ public @NonNull byte[] getX509Encoded() {
+ return x509Encoded;
+ }
+
+ public @NonNull PrivateKey getPrivateKey() {
+ return privateKey;
+ }
+ }
+
+ static final class ApprovingTrustManager implements X509TrustManager {
+
+ private @Nullable X509Certificate x509Certificate;
+
+ @Override
+ public void checkClientTrusted(@NonNull X509Certificate[] x509Certificates, @NonNull String authType) throws CertificateException {
+ throw new CertificateException();
+ }
+
+ @Override
+ public void checkServerTrusted(@NonNull X509Certificate[] x509Certificates, @NonNull String authType) throws CertificateException {
+ if (x509Certificates.length != 1) {
+ throw new CertificateException("More than 1 x509 certificate");
+ }
+
+ this.x509Certificate = x509Certificates[0];
+ }
+
+ @Override
+ public @NonNull X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+
+ public @Nullable X509Certificate getX509Certificate() {
+ return x509Certificate;
+ }
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java
index 5d27b28c2d..bfe1bdede0 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java
@@ -5,6 +5,6 @@ package org.signal.devicetransfer;
* {@link DeviceToDeviceTransferService} that an internal issue caused a shutdown and the
* service should stop as well.
*/
-public interface ShutdownCallback {
+interface ShutdownCallback {
void shutdown();
}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java
deleted file mode 100644
index 9f2b767ef4..0000000000
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.signal.devicetransfer;
-
-public enum TransferMode {
- PERMISSIONS,
- UNAVAILABLE,
- FAILED,
- READY,
- STARTING_UP,
- DISCOVERY,
- NETWORK_CONNECTED,
- SERVICE_CONNECTED
-}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java
new file mode 100644
index 0000000000..2869cfb260
--- /dev/null
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java
@@ -0,0 +1,82 @@
+package org.signal.devicetransfer;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Represents the status of the transfer.
+ */
+public class TransferStatus {
+
+ private final TransferMode transferMode;
+ private final int authenticationCode;
+
+ private TransferStatus(@NonNull TransferMode transferMode) {
+ this(transferMode, 0);
+ }
+
+ private TransferStatus(int authenticationCode) {
+ this(TransferMode.VERIFICATION_REQUIRED, authenticationCode);
+ }
+
+ private TransferStatus(@NonNull TransferMode transferMode, int authenticationCode) {
+ this.transferMode = transferMode;
+ this.authenticationCode = authenticationCode;
+ }
+
+ public @NonNull TransferMode getTransferMode() {
+ return transferMode;
+ }
+
+ public int getAuthenticationCode() {
+ return authenticationCode;
+ }
+
+ public static @NonNull TransferStatus ready() {
+ return new TransferStatus(TransferMode.READY);
+ }
+
+ public static @NonNull TransferStatus serviceConnected() {
+ return new TransferStatus(TransferMode.SERVICE_CONNECTED);
+ }
+
+ public static @NonNull TransferStatus networkConnected() {
+ return new TransferStatus(TransferMode.NETWORK_CONNECTED);
+ }
+
+ public static @NonNull TransferStatus verificationRequired(@NonNull Integer authenticationCode) {
+ return new TransferStatus(authenticationCode);
+ }
+
+ public static @NonNull TransferStatus startingUp() {
+ return new TransferStatus(TransferMode.STARTING_UP);
+ }
+
+ public static @NonNull TransferStatus discovery() {
+ return new TransferStatus(TransferMode.DISCOVERY);
+ }
+
+ public static @NonNull TransferStatus unavailable() {
+ return new TransferStatus(TransferMode.UNAVAILABLE);
+ }
+
+ public static @NonNull TransferStatus failed() {
+ return new TransferStatus(TransferMode.FAILED);
+ }
+
+ public static @NonNull TransferStatus shutdown() {
+ return new TransferStatus(TransferMode.SHUTDOWN);
+ }
+
+ public enum TransferMode {
+ UNAVAILABLE,
+ FAILED,
+ READY,
+ STARTING_UP,
+ DISCOVERY,
+ NETWORK_CONNECTED,
+ VERIFICATION_REQUIRED,
+ SERVICE_CONNECTED,
+ SERVICE_DISCONNECTED,
+ SHUTDOWN,
+ }
+}
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java
index 05dbb02616..d9dc3285d6 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java
@@ -18,9 +18,11 @@ import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceInfo;
import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceRequest;
import android.os.Build;
import android.os.HandlerThread;
+import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
@@ -32,12 +34,13 @@ import org.signal.devicetransfer.WifiDirectUnavailableException.Reason;
import java.util.Collections;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
* Provide the ability to spin up a WiFi Direct network, advertise a network service,
* discover a network service, and then connect two devices.
*/
-@SuppressLint("MissingPermission")
public final class WifiDirect {
private static final String TAG = Log.tag(WifiDirect.class);
@@ -49,8 +52,10 @@ public final class WifiDirect {
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION);
}};
- public static final String SERVICE_INSTANCE = "_devicetransfer._signal.org";
- public static final String SERVICE_REG_TYPE = "_presence._tcp";
+ private static final String EXTRA_INFO_PLACEHOLDER = "%%EXTRA_INFO%%";
+ private static final String SERVICE_INSTANCE_TEMPLATE = "_devicetransfer" + EXTRA_INFO_PLACEHOLDER + "._signal.org";
+ private static final Pattern SERVICE_INSTANCE_PATTERN = Pattern.compile("_devicetransfer(\\._(.+))?\\._signal\\.org");
+ private static final String SERVICE_REG_TYPE = "_presence._tcp";
private final Context context;
private WifiDirectConnectionListener connectionListener;
@@ -85,7 +90,7 @@ public final class WifiDirect {
: AvailableStatus.WIFI_DIRECT_NOT_AVAILABLE;
}
- public WifiDirect(@NonNull Context context) {
+ WifiDirect(@NonNull Context context) {
this.context = context.getApplicationContext();
this.wifiDirectCallbacksHandler = SignalExecutors.getAndStartHandlerThread("wifi-direct-cb");
}
@@ -95,7 +100,7 @@ public final class WifiDirect {
* with the Android WiFi Direct APIs. This should have a matching call to {@link #shutdown()} to
* release the various resources used to establish and maintain a WiFi Direct network.
*/
- public synchronized void initialize(@NonNull WifiDirectConnectionListener connectionListener) throws WifiDirectUnavailableException {
+ synchronized void initialize(@NonNull WifiDirectConnectionListener connectionListener) throws WifiDirectUnavailableException {
if (isInitialized()) {
Log.w(TAG, "Already initialized, do not need to initialize twice");
return;
@@ -128,7 +133,7 @@ public final class WifiDirect {
* Note: After this call, the instance is no longer usable and an entirely new one will need to
* be created.
*/
- public synchronized void shutdown() {
+ synchronized void shutdown() {
Log.d(TAG, "Shutting down");
connectionListener = null;
@@ -158,12 +163,15 @@ public final class WifiDirect {
* Start advertising a transfer service that other devices can search for and decide
* to connect to. Call on an appropriate thread as this method synchronously calls WiFi Direct
* methods.
+ *
+ * @param extraInfo Extra info to include in the service instance name (e.g., server port)
*/
@WorkerThread
- public synchronized void startDiscoveryService() throws WifiDirectUnavailableException {
+ @SuppressLint("MissingPermission")
+ synchronized void startDiscoveryService(@NonNull String extraInfo) throws WifiDirectUnavailableException {
ensureInitialized();
- WifiP2pDnsSdServiceInfo serviceInfo = WifiP2pDnsSdServiceInfo.newInstance(SERVICE_INSTANCE, SERVICE_REG_TYPE, Collections.emptyMap());
+ WifiP2pDnsSdServiceInfo serviceInfo = WifiP2pDnsSdServiceInfo.newInstance(buildServiceInstanceName(extraInfo), SERVICE_REG_TYPE, Collections.emptyMap());
SyncActionListener addLocalServiceListener = new SyncActionListener("add local service");
manager.addLocalService(channel, serviceInfo, addLocalServiceListener);
@@ -176,12 +184,23 @@ public final class WifiDirect {
}
}
+ /**
+ * Stop all peer discovery and advertising services.
+ */
+ synchronized void stopDiscoveryService() throws WifiDirectUnavailableException {
+ ensureInitialized();
+
+ retry(manager::stopPeerDiscovery, "stop peer discovery");
+ retry(manager::clearLocalServices, "clear local services");
+ }
+
/**
* Start searching for a transfer service being advertised by another device. Call on an
* appropriate thread as this method synchronously calls WiFi Direct methods.
*/
@WorkerThread
- public synchronized void discoverService() throws WifiDirectUnavailableException {
+ @SuppressLint("MissingPermission")
+ synchronized void discoverService() throws WifiDirectUnavailableException {
ensureInitialized();
if (serviceRequest != null) {
@@ -192,10 +211,11 @@ public final class WifiDirect {
WifiP2pManager.DnsSdTxtRecordListener txtListener = (fullDomain, record, device) -> {};
WifiP2pManager.DnsSdServiceResponseListener serviceListener = (instanceName, registrationType, sourceDevice) -> {
- if (SERVICE_INSTANCE.equals(instanceName)) {
+ String extraInfo = isInstanceNameMatching(instanceName);
+ if (extraInfo != null) {
Log.d(TAG, "Service found!");
if (connectionListener != null) {
- connectionListener.onServiceDiscovered(sourceDevice);
+ connectionListener.onServiceDiscovered(sourceDevice, extraInfo);
}
} else {
Log.d(TAG, "Found unusable service, ignoring.");
@@ -219,18 +239,29 @@ public final class WifiDirect {
}
}
+ /**
+ * Stop searching for transfer services.
+ */
+ synchronized void stopServiceDiscovery() throws WifiDirectUnavailableException {
+ ensureInitialized();
+
+ retry(manager::clearServiceRequests, "clear service requests");
+ }
+
/**
* Establish a WiFi Direct network by connecting to the given device address (MAC). An
* address can be found by using {@link #discoverService()}.
*
* @param deviceAddress Device MAC address to establish a connection with
*/
- public synchronized void connect(@NonNull String deviceAddress) throws WifiDirectUnavailableException {
+ @SuppressLint("MissingPermission")
+ synchronized void connect(@NonNull String deviceAddress) throws WifiDirectUnavailableException {
ensureInitialized();
WifiP2pConfig config = new WifiP2pConfig();
- config.deviceAddress = deviceAddress;
- config.wps.setup = WpsInfo.PBC;
+ config.deviceAddress = deviceAddress;
+ config.wps.setup = WpsInfo.PBC;
+ config.groupOwnerIntent = 0;
if (serviceRequest != null) {
manager.removeServiceRequest(channel, serviceRequest, LoggingActionListener.message("Remote service request"));
@@ -275,6 +306,24 @@ public final class WifiDirect {
}
}
+ @VisibleForTesting
+ static @NonNull String buildServiceInstanceName(@Nullable String extraInfo) {
+ if (TextUtils.isEmpty(extraInfo)) {
+ return SERVICE_INSTANCE_TEMPLATE.replace(EXTRA_INFO_PLACEHOLDER, "");
+ }
+ return SERVICE_INSTANCE_TEMPLATE.replace(EXTRA_INFO_PLACEHOLDER, "._" + extraInfo);
+ }
+
+ @VisibleForTesting
+ static @Nullable String isInstanceNameMatching(@NonNull String serviceInstanceName) {
+ Matcher matcher = SERVICE_INSTANCE_PATTERN.matcher(serviceInstanceName);
+ if (matcher.matches()) {
+ String extraInfo = matcher.group(2);
+ return TextUtils.isEmpty(extraInfo) ? "" : extraInfo;
+ }
+ return null;
+ }
+
private interface ManagerRetry {
void call(@NonNull WifiP2pManager.Channel a, @NonNull WifiP2pManager.ActionListener b);
}
@@ -405,7 +454,7 @@ public final class WifiDirect {
public interface WifiDirectConnectionListener {
void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice);
- void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice);
+ void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo);
void onNetworkConnected(@NonNull WifiP2pInfo info);
diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java
index e1cdd81f3c..18a32ab268 100644
--- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java
+++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java
@@ -5,7 +5,7 @@ import androidx.annotation.NonNull;
/**
* Represents the various type of failure with creating a WiFi Direction connection.
*/
-public final class WifiDirectUnavailableException extends Exception {
+final class WifiDirectUnavailableException extends Exception {
private final Reason reason;
diff --git a/device-transfer/lib/src/test/java/org/signal/devicetransfer/DeviceTransferAuthenticationTest.java b/device-transfer/lib/src/test/java/org/signal/devicetransfer/DeviceTransferAuthenticationTest.java
new file mode 100644
index 0000000000..909e913f61
--- /dev/null
+++ b/device-transfer/lib/src/test/java/org/signal/devicetransfer/DeviceTransferAuthenticationTest.java
@@ -0,0 +1,88 @@
+package org.signal.devicetransfer;
+
+import androidx.annotation.NonNull;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.signal.devicetransfer.DeviceTransferAuthentication.Client;
+import org.signal.devicetransfer.DeviceTransferAuthentication.DeviceTransferAuthenticationException;
+import org.signal.devicetransfer.DeviceTransferAuthentication.Server;
+
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+public class DeviceTransferAuthenticationTest {
+
+ private static byte[] certificate;
+ private static byte[] badCertificate;
+
+ @BeforeClass
+ public static void setup() throws KeyGenerationFailedException {
+ certificate = SelfSignedIdentity.create().getX509Encoded();
+ badCertificate = SelfSignedIdentity.create().getX509Encoded();
+ }
+
+ @Test
+ public void testCompute_withNoChanges() throws DeviceTransferAuthenticationException {
+ Client client = new Client(certificate);
+ Server server = new Server(certificate, client.getCommitment());
+
+ byte[] clientRandom = client.setServerRandomAndGetClientRandom(server.getRandom());
+
+ server.setClientRandom(clientRandom);
+ assertEquals(client.computeShortAuthenticationCode(), server.computeShortAuthenticationCode());
+ }
+
+ @Test(expected = DeviceTransferAuthenticationException.class)
+ public void testServerCompute_withChangedClientCertificate() throws DeviceTransferAuthenticationException {
+ Client client = new Client(badCertificate);
+ Server server = new Server(certificate, client.getCommitment());
+
+ byte[] clientRandom = client.setServerRandomAndGetClientRandom(server.getRandom());
+
+ server.setClientRandom(clientRandom);
+ server.computeShortAuthenticationCode();
+ }
+
+ @Test(expected = DeviceTransferAuthenticationException.class)
+ public void testServerCompute_withChangedClientCommitment() throws DeviceTransferAuthenticationException {
+ Client client = new Client(certificate);
+ Server server = new Server(certificate, randomBytes());
+
+ byte[] clientRandom = client.setServerRandomAndGetClientRandom(server.getRandom());
+
+ server.setClientRandom(clientRandom);
+ server.computeShortAuthenticationCode();
+ }
+
+ @Test(expected = DeviceTransferAuthenticationException.class)
+ public void testServerCompute_withChangedClientRandom() throws DeviceTransferAuthenticationException {
+ Client client = new Client(certificate);
+ Server server = new Server(certificate, client.getCommitment());
+
+ client.setServerRandomAndGetClientRandom(server.getRandom());
+
+ server.setClientRandom(randomBytes());
+ server.computeShortAuthenticationCode();
+ }
+
+ @Test
+ public void testClientCompute_withChangedServerSecret() throws DeviceTransferAuthenticationException {
+ Client client = new Client(certificate);
+ Server server = new Server(certificate, client.getCommitment());
+
+ byte[] clientRandom = client.setServerRandomAndGetClientRandom(randomBytes());
+
+ server.setClientRandom(clientRandom);
+ assertNotEquals(client.computeShortAuthenticationCode(), server.computeShortAuthenticationCode());
+ }
+
+ private @NonNull byte[] randomBytes() {
+ byte[] bytes = new byte[32];
+ new Random().nextBytes(bytes);
+ return bytes;
+ }
+}
diff --git a/device-transfer/lib/src/test/java/org/signal/devicetransfer/WifiDirectTest.java b/device-transfer/lib/src/test/java/org/signal/devicetransfer/WifiDirectTest.java
new file mode 100644
index 0000000000..5b64cdd7af
--- /dev/null
+++ b/device-transfer/lib/src/test/java/org/signal/devicetransfer/WifiDirectTest.java
@@ -0,0 +1,42 @@
+package org.signal.devicetransfer;
+
+import android.app.Application;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE, application = Application.class)
+public class WifiDirectTest {
+
+ @Test
+ public void instanceName_withExtraInfo() {
+ String instanceName = WifiDirect.buildServiceInstanceName("knownothing");
+
+ assertEquals("_devicetransfer._knownothing._signal.org", instanceName);
+
+ String extractedExtraInfo = WifiDirect.isInstanceNameMatching(instanceName);
+ assertEquals(extractedExtraInfo, "knownothing");
+ }
+
+ @Test
+ public void instanceName_matchingWithoutExtraInfo() {
+ String instanceName = WifiDirect.buildServiceInstanceName("");
+
+ assertEquals("_devicetransfer._signal.org", instanceName);
+
+ String extractedExtraInfo = WifiDirect.isInstanceNameMatching(instanceName);
+ assertEquals(extractedExtraInfo, "");
+ }
+
+ @Test
+ public void instanceName_notMatching() {
+ String extractedExtraInfo = WifiDirect.isInstanceNameMatching("_whoknows._what.org");
+ assertNull(extractedExtraInfo);
+ }
+}
\ No newline at end of file
diff --git a/device-transfer/lib/witness-verifications.gradle b/device-transfer/lib/witness-verifications.gradle
index cf44f2ad36..fe3cb05f94 100644
--- a/device-transfer/lib/witness-verifications.gradle
+++ b/device-transfer/lib/witness-verifications.gradle
@@ -6,9 +6,6 @@ dependencyVerification {
['androidx.activity:activity:1.0.0',
'd1bc9842455c2e534415d88c44df4d52413b478db9093a1ba36324f705f44c3d'],
- ['androidx.annotation:annotation-experimental:1.0.0',
- 'b219d2b568e7e4ba534e09f8c2fd242343df6ccbdfbbe938846f5d740e6b0b11'],
-
['androidx.annotation:annotation:1.1.0',
'd38d63edb30f1467818d50aaf05f8a692dea8b31392a049bfa991b159ad5b692'],
@@ -24,15 +21,9 @@ dependencyVerification {
['androidx.arch.core:core-runtime:2.0.0',
'87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e'],
- ['androidx.cardview:cardview:1.0.0',
- '1193c04c22a3d6b5946dae9f4e8c59d6adde6a71b6bd5d87fb99d82dda1afec7'],
-
['androidx.collection:collection:1.1.0',
'632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72'],
- ['androidx.coordinatorlayout:coordinatorlayout:1.1.0',
- '44a9e30abf56af1025c52a0af506fee9c4131aa55efda52f9fd9451211c5e8cb'],
-
['androidx.core:core:1.3.0',
'1c6b6626f15185d8f4bc7caac759412a1ab6e851ecf7526387d9b9fadcabdb63'],
@@ -69,15 +60,9 @@ dependencyVerification {
['androidx.loader:loader:1.0.0',
'11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025'],
- ['androidx.recyclerview:recyclerview:1.1.0',
- 'f0d2b5a67d0a91ee1b1c73ef2b636a81f3563925ddd15a1d4e1c41ec28de7a4f'],
-
['androidx.savedstate:savedstate:1.0.0',
'2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83'],
- ['androidx.transition:transition:1.2.0',
- 'a1e059b3bc0b43a58dec0efecdcaa89c82d2bca552ea5bacf6656c46e853157e'],
-
['androidx.vectordrawable:vectordrawable-animated:1.1.0',
'76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8'],
@@ -87,19 +72,31 @@ dependencyVerification {
['androidx.versionedparcelable:versionedparcelable:1.1.0',
'9a1d77140ac222b7866b5054ee7d159bc1800987ed2d46dd6afdd145abb710c1'],
- ['androidx.viewpager2:viewpager2:1.0.0',
- 'e95c0031d4cc247cd48196c6287e58d2cee54d9c79b85afea7c90920330275af'],
-
['androidx.viewpager:viewpager:1.0.0',
'147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682'],
- ['com.google.android.material:material:1.2.1',
- 'd3d0cc776f2341da8e572586c7d390a5b356ce39a0deb2768071dc40b364ac80'],
-
['com.google.protobuf:protobuf-javalite:3.10.0',
'215a94dbe100130295906b531bb72a26965c7ac8fcd9a75bf8054a8ac2abf4b4'],
+ ['com.madgag.spongycastle:core:1.58.0.0',
+ '199617dd5698c5a9312b898c0a4cec7ce9dd8649d07f65d91629f58229d72728'],
+
+ ['com.madgag.spongycastle:pg:1.54.0.0',
+ '3f1011ec280c51434dd94396ec25c8d7876d861c0fb1fa9ae70824eddcda2f8f'],
+
+ ['com.madgag.spongycastle:pkix:1.54.0.0',
+ '721a302f5ce18bf6fff89d514ef224c37b5dd9ca67a16b56fafaea4b24a51482'],
+
+ ['com.madgag.spongycastle:prov:1.58.0.0',
+ '092fd09e7006b0814980513b013d4c2b3ffd24a49a635ab4b2d204bb51af1727'],
+
+ ['junit:junit:4.12',
+ '59721f0805e223d84b90677887d9ff567dc534d7c502ca903c0c2b17f05c116a'],
+
['org.greenrobot:eventbus:3.0.0',
'180d4212467df06f2fbc9c8d8a2984533ac79c87769ad883bc421612f0b4e17c'],
+
+ ['org.hamcrest:hamcrest-core:1.3',
+ '66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9'],
]
}