From 5b61c8ac18a7e89c981188edf0a11bbec00a36c2 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Wed, 10 Jul 2019 16:59:08 -0400 Subject: [PATCH] Request a push challenge and supply to SMS and voice verification. --- .../securesms/RegistrationActivity.java | 32 ++++- .../securesms/gcm/FcmService.java | 23 +++- .../registration/PushChallengeRequest.java | 121 ++++++++++++++++++ .../PushChallengeRequestTest.java | 115 +++++++++++++++++ 4 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/registration/PushChallengeRequest.java create mode 100644 test/unitTest/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java diff --git a/src/org/thoughtcrime/securesms/RegistrationActivity.java b/src/org/thoughtcrime/securesms/RegistrationActivity.java index 7ea6538319..17f81cf1a7 100644 --- a/src/org/thoughtcrime/securesms/RegistrationActivity.java +++ b/src/org/thoughtcrime/securesms/RegistrationActivity.java @@ -9,9 +9,6 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.AsyncTask; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; @@ -28,6 +25,10 @@ import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + import com.dd.CircularProgressButton; import com.google.android.gms.auth.api.phone.SmsRetriever; import com.google.android.gms.auth.api.phone.SmsRetrieverClient; @@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.push.AccountManagerFactory; import org.thoughtcrime.securesms.registration.CaptchaActivity; +import org.thoughtcrime.securesms.registration.PushChallengeRequest; import org.thoughtcrime.securesms.service.DirectoryRefreshListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.VerificationCodeParser; @@ -115,7 +117,9 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif private static final int SCENE_TRANSITION_DURATION = 250; private static final int DEBUG_TAP_TARGET = 8; private static final int DEBUG_TAP_ANNOUNCE = 4; - public static final String RE_REGISTRATION_EXTRA = "re_registration"; + private static final long PUSH_REQUEST_TIMEOUT_MS = 5000L; + + public static final String RE_REGISTRATION_EXTRA = "re_registration"; private static final String TAG = RegistrationActivity.class.getSimpleName(); @@ -492,7 +496,10 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif } accountManager = AccountManagerFactory.createManager(RegistrationActivity.this, e164number, password); - accountManager.requestSmsVerificationCode(smsRetrieverSupported, registrationState.captchaToken, Optional.absent()); + + Optional pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, fcmToken, e164number, PUSH_REQUEST_TIMEOUT_MS); + + accountManager.requestSmsVerificationCode(smsRetrieverSupported, registrationState.captchaToken, pushChallenge); return new VerificationRequestResult(password, fcmToken, Optional.absent()); } catch (IOException e) { @@ -668,6 +675,8 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif @SuppressLint("StaticFieldLeak") private void handlePhoneCallRequest() { + final String e164number = getConfiguredE164Number(); + if (registrationState.state == RegistrationState.State.VERIFYING) { callMeCountDownView.startCountDown(300); @@ -675,7 +684,9 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif @Override protected Void doInBackground(Void... voids) { try { - accountManager.requestVoiceVerificationCode(Locale.getDefault(), registrationState.captchaToken, Optional.absent()); + Optional pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, getFcmToken(), e164number, PUSH_REQUEST_TIMEOUT_MS); + + accountManager.requestVoiceVerificationCode(Locale.getDefault(), registrationState.captchaToken, pushChallenge); } catch (CaptchaRequiredException e) { requestCaptcha(false); } catch (IOException e) { @@ -688,6 +699,15 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif } } + private Optional getFcmToken() { + final boolean gcmSupported = PlayServicesUtil.getPlayServicesStatus(this) == PlayServicesStatus.SUCCESS; + if (gcmSupported) { + return FcmUtil.getToken(); + } else { + return Optional.absent(); + } + } + private void verifyAccount(@NonNull String code, @Nullable String pin) throws IOException { int registrationId = KeyHelper.generateRegistrationId(false); byte[] unidentifiedAccessKey = UnidentifiedAccessUtil.getSelfUnidentifiedAccessKey(RegistrationActivity.this); diff --git a/src/org/thoughtcrime/securesms/gcm/FcmService.java b/src/org/thoughtcrime/securesms/gcm/FcmService.java index 6f0fd1f5e3..8611f317d5 100644 --- a/src/org/thoughtcrime/securesms/gcm/FcmService.java +++ b/src/org/thoughtcrime/securesms/gcm/FcmService.java @@ -4,6 +4,8 @@ import android.content.Context; import android.os.Build; import android.os.PowerManager; +import androidx.annotation.NonNull; + import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; @@ -13,6 +15,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.registration.PushChallengeRequest; import org.thoughtcrime.securesms.util.PowerManagerCompat; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -38,11 +41,17 @@ public class FcmService extends FirebaseMessagingService implements InjectableTy @Override public void onMessageReceived(RemoteMessage remoteMessage) { Log.i(TAG, "FCM message... Original Priority: " + remoteMessage.getOriginalPriority() + ", Actual Priority: " + remoteMessage.getPriority()); - ApplicationContext.getInstance(getApplicationContext()).injectDependencies(this); - WakeLockUtil.runWithLock(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK, 60000, WAKE_LOCK_TAG, () -> { - handleReceivedNotification(getApplicationContext()); - }); + String challenge = remoteMessage.getData().get("challenge"); + if (challenge != null) { + handlePushChallenge(challenge); + } else { + ApplicationContext.getInstance(getApplicationContext()).injectDependencies(this); + + WakeLockUtil.runWithLock(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK, 60000, WAKE_LOCK_TAG, () -> + handleReceivedNotification(getApplicationContext()) + ); + } } @Override @@ -95,6 +104,12 @@ public class FcmService extends FirebaseMessagingService implements InjectableTy Log.i(TAG, "Processing complete."); } + private static void handlePushChallenge(@NonNull String challenge) { + Log.d(TAG, String.format("Got a push challenge \"%s\"", challenge)); + + PushChallengeRequest.postChallengeResponse(challenge); + } + private static synchronized boolean incrementActiveGcmCount() { if (activeCount < 2) { activeCount++; diff --git a/src/org/thoughtcrime/securesms/registration/PushChallengeRequest.java b/src/org/thoughtcrime/securesms/registration/PushChallengeRequest.java new file mode 100644 index 0000000000..802518e0e0 --- /dev/null +++ b/src/org/thoughtcrime/securesms/registration/PushChallengeRequest.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.registration; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.io.IOException; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public final class PushChallengeRequest { + + private static final String TAG = Log.tag(PushChallengeRequest.class); + + /** + * Requests a push challenge and waits for the response. + *

+ * Blocks the current thread for up to {@param timeoutMs} milliseconds. + * + * @param accountManager Account manager to request the push from. + * @param fcmToken Optional FCM token. If not present will return absent. + * @param e164number Local number. + * @param timeoutMs Timeout in milliseconds + * @return Either returns a challenge, or absent. + */ + @WorkerThread + public static Optional getPushChallengeBlocking(@NonNull SignalServiceAccountManager accountManager, + @NonNull Optional fcmToken, + @NonNull String e164number, + long timeoutMs) + { + if (!fcmToken.isPresent()) { + Log.w(TAG, "Push challenge not requested, as no FCM token was present"); + return Optional.absent(); + } + + long startTime = System.currentTimeMillis(); + Log.i(TAG, "Requesting a push challenge"); + + Request request = new Request(accountManager, fcmToken.get(), e164number, timeoutMs); + + Optional challenge = request.requestAndReceiveChallengeBlocking(); + + long duration = System.currentTimeMillis() - startTime; + + if (challenge.isPresent()) { + Log.i(TAG, String.format(Locale.US, "Received a push challenge \"%s\" in %d ms", challenge.get(), duration)); + } else { + Log.w(TAG, String.format(Locale.US, "Did not received a push challenge in %d ms", duration)); + } + return challenge; + } + + public static void postChallengeResponse(@NonNull String challenge) { + EventBus.getDefault().post(new PushChallengeEvent(challenge)); + } + + private static class Request { + + private final CountDownLatch latch; + private final AtomicReference challenge; + private final SignalServiceAccountManager accountManager; + private final String fcmToken; + private final String e164number; + private final long timeoutMs; + + private Request(@NonNull SignalServiceAccountManager accountManager, + @NonNull String fcmToken, + @NonNull String e164number, + long timeoutMs) + { + this.latch = new CountDownLatch(1); + this.challenge = new AtomicReference<>(); + this.accountManager = accountManager; + this.fcmToken = fcmToken; + this.e164number = e164number; + this.timeoutMs = timeoutMs; + } + + @WorkerThread + private Optional requestAndReceiveChallengeBlocking() { + EventBus eventBus = EventBus.getDefault(); + + eventBus.register(this); + try { + accountManager.requestPushChallenge(fcmToken, e164number); + + latch.await(timeoutMs, TimeUnit.MILLISECONDS); + + return Optional.fromNullable(challenge.get()); + } catch (InterruptedException | IOException e) { + Log.w(TAG, "Error getting push challenge", e); + return Optional.absent(); + } finally { + eventBus.unregister(this); + } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + public void onChallengeEvent(@NonNull PushChallengeEvent pushChallengeEvent) { + challenge.set(pushChallengeEvent.challenge); + latch.countDown(); + } + } + + static class PushChallengeEvent { + private final String challenge; + + PushChallengeEvent(String challenge) { + this.challenge = challenge; + } + } +} diff --git a/test/unitTest/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java b/test/unitTest/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java new file mode 100644 index 0000000000..3992b71ad8 --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.registration; + +import android.app.Application; +import android.os.AsyncTask; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.io.IOException; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE, application = Application.class) +public final class PushChallengeRequestTest { + + @Test + public void getPushChallengeBlocking_returns_absent_if_times_out() { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 50L); + + assertFalse(challenge.isPresent()); + } + + @Test + public void getPushChallengeBlocking_waits_for_specified_period() { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + long startTime = System.currentTimeMillis(); + PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 250L); + long duration = System.currentTimeMillis() - startTime; + + assertThat(duration, greaterThanOrEqualTo(250L)); + } + + @Test + public void getPushChallengeBlocking_completes_fast_if_posted_to_event_bus() throws IOException { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + doAnswer(invocation -> { + AsyncTask.execute(() -> PushChallengeRequest.postChallengeResponse("CHALLENGE")); + return null; + }).when(signal).requestPushChallenge("token", "+123456"); + + long startTime = System.currentTimeMillis(); + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L); + long duration = System.currentTimeMillis() - startTime; + + assertThat(duration, lessThan(500L)); + verify(signal).requestPushChallenge("token", "+123456"); + verifyNoMoreInteractions(signal); + + assertTrue(challenge.isPresent()); + assertEquals("CHALLENGE", challenge.get()); + } + + @Test + public void getPushChallengeBlocking_returns_fast_if_no_fcm_token_supplied() { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + long startTime = System.currentTimeMillis(); + PushChallengeRequest.getPushChallengeBlocking(signal, Optional.absent(), "+123456", 500L); + long duration = System.currentTimeMillis() - startTime; + + assertThat(duration, lessThan(500L)); + } + + @Test + public void getPushChallengeBlocking_returns_absent_if_no_fcm_token_supplied() { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.absent(), "+123456", 500L); + + verifyZeroInteractions(signal); + assertFalse(challenge.isPresent()); + } + + @Test + public void getPushChallengeBlocking_returns_absent_if_any_IOException_is_thrown() throws IOException { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + doThrow(new IOException()).when(signal).requestPushChallenge(any(), any()); + + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L); + + assertFalse(challenge.isPresent()); + } + + @Test + public void getPushChallengeBlocking_returns_absent_if_any_RuntimeException_is_thrown() throws IOException { + SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); + + doThrow(new RuntimeException()).when(signal).requestPushChallenge(any(), any()); + + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L); + + assertFalse(challenge.isPresent()); + } +}