From e74460bd91245619f9d0c1dbbb5ca99b41ed1f09 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 11 Mar 2021 13:16:51 -0500 Subject: [PATCH] Enable TLS connection and SAS verification between device transfer server and client. --- device-transfer/app/build.gradle | 19 ++ device-transfer/app/proguard/proguard.cfg | 7 + .../devicetransfer/app/MainActivity.java | 35 ++- device-transfer/lib/build.gradle | 17 +- device-transfer/lib/lib-proguard-rules.pro | 8 + .../org/signal/devicetransfer/ClientTask.java | 5 + .../DeviceToDeviceTransferService.java | 60 ++-- .../DeviceTransferAuthentication.java | 226 +++++++++++++++ .../devicetransfer/DeviceTransferClient.java | 248 ++++++++--------- .../devicetransfer/DeviceTransferServer.java | 257 +++++++++--------- .../org/signal/devicetransfer/IpExchange.java | 5 +- .../KeyGenerationFailedException.java | 12 + .../devicetransfer/NetworkClientThread.java | 169 ++++++++++++ .../devicetransfer/NetworkServerThread.java | 146 ++++++++++ .../devicetransfer/SelfSignedIdentity.java | 170 ++++++++++++ .../devicetransfer/ShutdownCallback.java | 2 +- .../signal/devicetransfer/TransferMode.java | 12 - .../signal/devicetransfer/TransferStatus.java | 82 ++++++ .../org/signal/devicetransfer/WifiDirect.java | 79 +++++- .../WifiDirectUnavailableException.java | 2 +- .../DeviceTransferAuthenticationTest.java | 88 ++++++ .../signal/devicetransfer/WifiDirectTest.java | 42 +++ .../lib/witness-verifications.gradle | 39 ++- 23 files changed, 1376 insertions(+), 354 deletions(-) create mode 100644 device-transfer/app/proguard/proguard.cfg create mode 100644 device-transfer/lib/lib-proguard-rules.pro create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferAuthentication.java create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/KeyGenerationFailedException.java create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/SelfSignedIdentity.java delete mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java create mode 100644 device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java create mode 100644 device-transfer/lib/src/test/java/org/signal/devicetransfer/DeviceTransferAuthenticationTest.java create mode 100644 device-transfer/lib/src/test/java/org/signal/devicetransfer/WifiDirectTest.java diff --git a/device-transfer/app/build.gradle b/device-transfer/app/build.gradle index 6bbc6ff01..b2f6d3e77 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 000000000..c6e97d049 --- /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 2f19ea0c3..c090e9301 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 c4352837d..5cf0e51d6 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 000000000..3540c440b --- /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 b506e8406..8b6689908 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 f7486a889..84cfdf6e2 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 000000000..75c176114 --- /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). + *
    + *
  1. Client generates a random data, and then MAC(k=random data, m=certificate) to get a commitment.
  2. + *
  3. Client sends commitment to the server.
  4. + *
  5. Server stores commitment and generates it's own random data.
  6. + *
  7. Server sends it's random data to client.
  8. + *
  9. Client stores server random data and sends it's random data to the server.
  10. + *
  11. Server can then MAC(k=client random data, m=certificate) to verify the original commitment.
  12. + *
  13. Client and Server can compute a SAS using the two randoms.
  14. + *
+ */ +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 e0b8548ee..71f741f71 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: *

*/ -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 5e83fdc07..6ec376b69 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 0cf16f5cb..ce79769e1 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 000000000..ac45f1ad0 --- /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 000000000..25e1b1933 --- /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 000000000..f0953888c --- /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 000000000..3b60cfd95 --- /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 5d27b28c2..bfe1bdede 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 9f2b767ef..000000000 --- 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 000000000..2869cfb26 --- /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 05dbb0261..d9dc3285d 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 e1cdd81f3..18a32ab26 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 000000000..909e913f6 --- /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 000000000..5b64cdd7a --- /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 cf44f2ad3..fe3cb05f9 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'], ] }