Handle 428 rate limiting.

This commit is contained in:
Greyson Parrelli 2021-05-05 12:49:18 -04:00
parent 02d060ca0a
commit 31e1c6f7aa
60 changed files with 1235 additions and 57 deletions

View file

@ -136,6 +136,7 @@ android {
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}"
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@ -285,6 +286,7 @@ android {
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
}
}

View file

@ -573,6 +573,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:launchMode="singleTask" />
<activity android:name=".ratelimit.RecaptchaProofActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" />
<activity android:name=".wallpaper.crop.WallpaperImageSelectionActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.FullScreenMedia" />

View file

@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
@ -156,6 +157,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(DownloadLatestEmojiDataJob::scheduleIfNecessary)
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
.execute();

View file

@ -64,6 +64,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable {
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onVoiceNotePause(@NonNull Uri uri);

View file

@ -69,4 +69,10 @@ public class AlertView extends LinearLayout {
approvalIndicator.setVisibility(View.GONE);
failedIndicator.setVisibility(View.VISIBLE);
}
public void setRateLimited() {
this.setVisibility(View.VISIBLE);
approvalIndicator.setVisibility(View.VISIBLE);
failedIndicator.setVisibility(View.GONE);
}
}

View file

@ -161,6 +161,8 @@ public class ConversationItemFooter extends LinearLayout {
dateView.setText(errorMsg);
} else if (messageRecord.isPendingInsecureSmsFallback()) {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}

View file

@ -228,6 +228,8 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofActivity;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
@ -579,6 +581,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
setVisibleThread(threadId);
ConversationUtil.refreshRecipientShortcuts();
if (SignalStore.rateLimit().needsRecaptcha()) {
RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager());
}
}
@Override
@ -2200,6 +2206,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
this.viewModel.setArgs(args);
this.viewModel.getWallpaper().observe(this, this::updateWallpaper);
this.viewModel.getEvents().observe(this, this::onViewModelEvent);
}
private void initializeGroupViewModel() {
@ -2973,6 +2980,14 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
}
private void onViewModelEvent(@NonNull ConversationViewModel.Event event) {
if (event == ConversationViewModel.Event.SHOW_RECAPTCHA) {
RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager());
} else {
throw new AssertionError("Unexpected event!");
}
}
private void updateLinkPreviewState() {
if (SignalStore.settings().isLinkPreviewsEnabled() && isSecureText && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) {
linkPreviewViewModel.onEnabled();

View file

@ -124,6 +124,7 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -1503,6 +1504,11 @@ public class ConversationFragment extends LoggingFragment {
listener.onMessageWithErrorClicked(messageRecord);
}
@Override
public void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord) {
RecaptchaProofBottomSheetFragment.show(getChildFragmentManager());
}
@Override
public void onVoiceNotePause(@NonNull Uri uri) {
voiceNoteMediaController.pausePlayback(uri);

View file

@ -97,6 +97,7 @@ import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil;
import org.thoughtcrime.securesms.mms.GlideRequests;
@ -1054,6 +1055,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
alertView.setFailed();
} else if (messageRecord.isPendingInsecureSmsFallback()) {
alertView.setPendingApproval();
} else if (messageRecord.isRateLimited()) {
alertView.setRateLimited();
} else {
alertView.setNone();
}
@ -1166,7 +1169,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp());
if (forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
current.isFailed() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread))
current.isFailed() || current.isRateLimited() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread))
{
ConversationItemFooter activeFooter = getActiveFooter(current);
activeFooter.setVisibility(VISIBLE);
@ -1213,6 +1216,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean shouldInterceptClicks(MessageRecord messageRecord) {
return batchSelected.isEmpty() &&
((messageRecord.isFailed() && !messageRecord.isMmsNotification()) ||
(messageRecord.isRateLimited() && SignalStore.rateLimit().needsRecaptcha()) ||
messageRecord.isPendingInsecureSmsFallback() ||
messageRecord.isBundleKeyExchange());
}
@ -1682,6 +1686,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (eventListener != null) {
eventListener.onMessageWithErrorClicked(messageRecord);
}
} else if (messageRecord.isRateLimited() && SignalStore.rateLimit().needsRecaptcha()) {
if (eventListener != null) {
eventListener.onMessageWithRecaptchaNeededClicked(messageRecord);
}
} else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) {
handleApproveIdentity();
} else if (messageRecord.isPendingInsecureSmsFallback()) {

View file

@ -10,6 +10,9 @@ import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
@ -19,8 +22,10 @@ import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaRepository;
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.Pair;
@ -28,7 +33,7 @@ import org.whispersystems.libsignal.util.Pair;
import java.util.List;
import java.util.Objects;
class ConversationViewModel extends ViewModel {
public class ConversationViewModel extends ViewModel {
private static final String TAG = Log.tag(ConversationViewModel.class);
@ -46,6 +51,7 @@ class ConversationViewModel extends ViewModel {
private final DatabaseObserver.Observer messageObserver;
private final MutableLiveData<RecipientId> recipientId;
private final LiveData<ChatWallpaper> wallpaper;
private final SingleLiveEvent<Event> events;
private ConversationIntents.Args args;
private int jumpToPosition;
@ -59,6 +65,7 @@ class ConversationViewModel extends ViewModel {
this.showScrollButtons = new MutableLiveData<>(false);
this.hasUnreadMentions = new MutableLiveData<>(false);
this.recipientId = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.pagingController = new ProxyPagingController();
this.messageObserver = pagingController::onDataInvalidated;
@ -108,6 +115,8 @@ class ConversationViewModel extends ViewModel {
wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getWallpaper));
EventBus.getDefault().register(this);
}
void onAttachmentKeyboardOpen() {
@ -144,6 +153,10 @@ class ConversationViewModel extends ViewModel {
return wallpaper;
}
@NonNull LiveData<Event> getEvents() {
return events;
}
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}
@ -184,10 +197,20 @@ class ConversationViewModel extends ViewModel {
return Objects.requireNonNull(args);
}
@Subscribe(threadMode = ThreadMode.POSTING)
public void onRecaptchaRequiredEvent(@NonNull RecaptchaRequiredEvent event) {
events.postValue(Event.SHOW_RECAPTCHA);
}
@Override
protected void onCleared() {
super.onCleared();
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver);
EventBus.getDefault().unregister(this);
}
enum Event {
SHOW_RECAPTCHA
}
static class Factory extends ViewModelProvider.NewInstanceFactory {

View file

@ -116,6 +116,8 @@ import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofActivity;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
@ -283,6 +285,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
SignalProxyUtil.startListeningToWebsocket();
if (SignalStore.rateLimit().needsRecaptcha()) {
Log.i(TAG, "Recaptcha required.");
RecaptchaProofBottomSheetFragment.show(getChildFragmentManager());
}
}
@Override

View file

@ -89,6 +89,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage();
public abstract boolean isSent(long messageId);
public abstract List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
public abstract Set<Long> getAllRateLimitedMessageIds();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime);
@ -101,6 +102,8 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract void markAsInsecure(long id);
public abstract void markAsPush(long id);
public abstract void markAsForcedSms(long id);
public abstract void markAsRateLimited(long id);
public abstract void clearRateLimitStatus(Collection<Long> ids);
public abstract void markAsDecryptFailed(long id);
public abstract void markAsDecryptDuplicate(long id);
public abstract void markAsNoSession(long id);

View file

@ -785,6 +785,30 @@ public class MmsDatabase extends MessageDatabase {
notifyConversationListeners(threadId);
}
@Override
public void markAsRateLimited(long messageId) {
long threadId = getThreadIdForMessage(messageId);
updateMailboxBitmask(messageId, 0, Types.MESSAGE_RATE_LIMITED_BIT, Optional.of(threadId));
notifyConversationListeners(threadId);
}
@Override
public void clearRateLimitStatus(@NonNull Collection<Long> ids) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (long id : ids) {
long threadId = getThreadIdForMessage(id);
updateMailboxBitmask(id, Types.MESSAGE_RATE_LIMITED_BIT, 0, Optional.of(threadId));
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
public void markAsPendingInsecureSmsFallback(long messageId) {
long threadId = getThreadIdForMessage(messageId);
@ -1708,6 +1732,22 @@ public class MmsDatabase extends MessageDatabase {
throw new UnsupportedOperationException();
}
@Override
public Set<Long> getAllRateLimitedMessageIds() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String where = "(" + MESSAGE_BOX + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0";
Set<Long> ids = new HashSet<>();
try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) {
while (cursor.moveToNext()) {
ids.add(CursorUtil.requireLong(cursor, ID));
}
}
return ids;
}
@Override
void deleteThreads(@NonNull Set<Long> threadIds) {
Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")");

View file

@ -28,7 +28,34 @@ public interface MmsSmsColumns {
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
public static final String REMOTE_DELETED = "remote_deleted";
/**
* For storage efficiency, all types are stored within a single 64-bit integer column in the
* database. There are various areas reserved for different classes of data.
*
* When carving out a new area, if it's storing a bunch of mutually-exclusive flags (like in
* {@link #BASE_TYPE_MASK}, you should store integers in that area. If multiple flags can be set
* within a category, you'll have to store them as bits. Just keep in mind that storing as bits
* means we can store less data (i.e. 4 bits can store 16 exclusive values, or 4 non-exclusive
* values). This was not always followed in the past, and now we've wasted some space.
*
* Note: We technically could use up to 64 bits, but {@link #TOTAL_MASK} is currently just set to
* look at 32. Theoretically if we needed more bits, we could just use them and expand the size of
* {@link #TOTAL_MASK}.
*
* <pre>
* _____________________________________ ENCRYPTION ({@link #ENCRYPTION_MASK})
* | _____________________________ SECURE MESSAGE INFORMATION (no mask, but look at {@link #SECURE_MESSAGE_BIT})
* | | ________________________ GROUPS (no mask, but look at {@link #GROUP_UPDATE_BIT})
* | | | _________________ KEY_EXCHANGE ({@link #KEY_EXCHANGE_MASK})
* | | | | _________ MESSAGE_ATTRIBUTES ({@link #MESSAGE_ATTRIBUTE_MASK})
* | | | | | ____ BASE_TYPE ({@link #BASE_TYPE_MASK})
* ___|___ _| _| ___|__ | __|_
* | | | | | | | | | || |
* 0000 0000 0000 0000 0000 0000 0000 0000
* </pre>
*/
public static class Types {
protected static final long TOTAL_MASK = 0xFFFFFFFF;
// Base Types
@ -64,7 +91,9 @@ public interface MmsSmsColumns {
// Message attributes
protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0;
protected static final long MESSAGE_RATE_LIMITED_BIT = 0x80;
protected static final long MESSAGE_FORCE_SMS_BIT = 0x40;
// Note: Might be wise to reserve 0x20 -- it would let us expand BASE_MASK by a bit if needed
// Key Exchange Information
protected static final long KEY_EXCHANGE_MASK = 0xFF00;
@ -210,6 +239,10 @@ public interface MmsSmsColumns {
return (type & KEY_EXCHANGE_IDENTITY_UPDATE_BIT) != 0;
}
public static boolean isRateLimited(long type) {
return (type & MESSAGE_RATE_LIMITED_BIT) != 0;
}
public static boolean isCallLog(long type) {
return isIncomingAudioCall(type) ||
isIncomingVideoCall(type) ||

View file

@ -47,7 +47,6 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo;
@ -321,6 +320,27 @@ public class SmsDatabase extends MessageDatabase {
updateTypeBitmask(id, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT);
}
@Override
public void markAsRateLimited(long id) {
updateTypeBitmask(id, 0, Types.MESSAGE_RATE_LIMITED_BIT);
}
@Override
public void clearRateLimitStatus(@NonNull Collection<Long> ids) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (long id : ids) {
updateTypeBitmask(id, Types.MESSAGE_RATE_LIMITED_BIT, 0);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
public void markAsDecryptFailed(long id) {
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT);
@ -887,6 +907,22 @@ public class SmsDatabase extends MessageDatabase {
return new Pair<>(messageId, threadId);
}
@Override
public Set<Long> getAllRateLimitedMessageIds() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String where = "(" + TYPE + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0";
Set<Long> ids = new HashSet<>();
try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) {
while (cursor.moveToNext()) {
ids.add(CursorUtil.requireLong(cursor, ID));
}
}
return ids;
}
@Override
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";

View file

@ -423,6 +423,10 @@ public abstract class MessageRecord extends DisplayRecord {
return SmsDatabase.Types.isContentBundleKeyExchange(type);
}
public boolean isRateLimited() {
return SmsDatabase.Types.isRateLimited(type);
}
public boolean isIdentityUpdate() {
return SmsDatabase.Types.isIdentityUpdate(type);
}

View file

@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.SubmitRateLimitPushChallengeJob;
import org.thoughtcrime.securesms.registration.PushChallengeRequest;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@ -30,9 +31,13 @@ public class FcmReceiveService extends FirebaseMessagingService {
remoteMessage.getPriority(),
remoteMessage.getOriginalPriority()));
String challenge = remoteMessage.getData().get("challenge");
if (challenge != null) {
handlePushChallenge(challenge);
String registrationChallenge = remoteMessage.getData().get("challenge");
String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
if (registrationChallenge != null) {
handleRegistrationPushChallenge(registrationChallenge);
} else if (rateLimitChallenge != null) {
handleRateLimitPushChallenge(rateLimitChallenge);
}else {
handleReceivedNotification(ApplicationDependencies.getApplication());
}
@ -75,9 +80,13 @@ public class FcmReceiveService extends FirebaseMessagingService {
}
}
private static void handlePushChallenge(@NonNull String challenge) {
Log.d(TAG, String.format("Got a push challenge \"%s\"", challenge));
private static void handleRegistrationPushChallenge(@NonNull String challenge) {
Log.d(TAG, "Got a registration push challenge.");
PushChallengeRequest.postChallengeResponse(challenge);
}
private static void handleRateLimitPushChallenge(@NonNull String challenge) {
Log.d(TAG, "Got a rate limit push challenge.");
ApplicationDependencies.getJobManager().add(new SubmitRateLimitPushChallengeJob(challenge));
}
}

View file

@ -176,6 +176,23 @@ class JobController {
.forEach(this::cancelJob);
}
@WorkerThread
synchronized void update(@NonNull JobUpdater updater) {
List<JobSpec> allJobs = jobStorage.getAllJobSpecs();
List<JobSpec> updatedJobs = new LinkedList<>();
for (JobSpec job : allJobs) {
JobSpec updated = updater.update(job, dataSerializer);
if (updated != job) {
updatedJobs.add(updated);
}
}
jobStorage.updateJobs(updatedJobs);
notifyAll();
}
@WorkerThread
synchronized void onRetry(@NonNull Job job, long backoffInterval) {
if (backoffInterval <= 0) {

View file

@ -223,6 +223,15 @@ public class JobManager implements ConstraintObserver.Notifier {
runOnExecutor(() -> jobController.cancelAllInQueue(queue));
}
/**
* Perform an arbitrary update on enqueued jobs. Will not apply to jobs that are already running.
* You shouldn't use this if you can help it. You give yourself an opportunity to really screw
* things up.
*/
public void update(@NonNull JobUpdater updater) {
runOnExecutor(() -> jobController.update(updater));
}
/**
* Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning
* you must take great care where you call this. It could take a very long time to complete!

View file

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
public interface JobUpdater {
/**
* Called for each enqueued job, giving you an opportunity to update each one.
*
* @param jobSpec An object representing data about an enqueued job.
* @param serializer An object that can be used to serialize/deserialize data if necessary for
* your update.
*
* @return The updated JobSpec you want persisted. If you do not wish to make an update, return
* the literal same JobSpec instance you were provided.
*/
@NonNull JobSpec update(@NonNull JobSpec jobSpec, @NonNull Data.Serializer serializer);
}

View file

@ -49,6 +49,10 @@ public final class JobSpec {
this.memoryOnly = memoryOnly;
}
public @NonNull JobSpec withNextRunAttemptTime(long updated) {
return new JobSpec(id, factoryKey, queueKey, createTime, updated, runAttempt, maxAttempts, lifespan, serializedData, serializedInputData, isRunning, memoryOnly);
}
public @NonNull String getId() {
return id;
}

View file

@ -145,6 +145,7 @@ public final class JobManagerFactories {
put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory());
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
put(TypingSendJob.KEY, new TypingSendJob.Factory());
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());

View file

@ -11,7 +11,6 @@ import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -55,6 +54,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2;
@ -197,6 +197,7 @@ public final class PushGroupSendJob extends PushSendJob {
List<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(findId(result.getAddress(), idByE164, idByUuid))).toList();
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(findId(result.getAddress(), idByE164, idByUuid), result.getIdentityFailure().getIdentityKey())).toList();
ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null);
List<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(findId(result.getAddress(), idByE164, idByUuid), result.getSuccess().isUnidentified())).toList();
Set<RecipientId> successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet());
@ -229,6 +230,10 @@ public final class PushGroupSendJob extends PushSendJob {
DatabaseFactory.getGroupReceiptDatabase(context).setUnidentified(successUnidentifiedStatus, messageId);
if (proofRequired != null) {
handleProofRequiredException(proofRequired, groupRecipient, threadId, messageId, true);
}
if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) {
database.markAsSent(messageId, true);
@ -391,6 +396,10 @@ public final class PushGroupSendJob extends PushSendJob {
return RecipientUtil.getEligibleForSending(members);
}
public static long getMessageId(@NonNull Data data) {
return data.getLong(KEY_MESSAGE_ID);
}
public static class Factory implements Job.Factory<PushGroupSendJob> {
@Override
public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) {

View file

@ -8,7 +8,6 @@ import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -41,6 +40,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Pr
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
@ -105,7 +105,7 @@ public class PushMediaSendJob extends PushSendJob {
@Override
public void onPushSend()
throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException
throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException
{
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageDatabase database = DatabaseFactory.getMmsDatabase(context);
@ -172,6 +172,8 @@ public class PushMediaSendJob extends PushSendJob {
database.addMismatchedIdentity(messageId, recipientId, uie.getIdentityKey());
database.markAsSentFailed(messageId);
RetrieveProfileJob.enqueue(recipientId);
} catch (ProofRequiredException e) {
handleProofRequiredException(e, DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId), threadId, messageId, true);
}
}
@ -235,6 +237,10 @@ public class PushMediaSendJob extends PushSendJob {
}
}
public static long getMessageId(@NonNull Data data) {
return data.getLong(KEY_MESSAGE_ID);
}
public static final class Factory implements Job.Factory<PushMediaSendJob> {
@Override
public @NonNull PushMediaSendJob create(@NonNull Parameters parameters, @NonNull Data data) {

View file

@ -11,6 +11,9 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.registration.PushChallengeRequest;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
@ -49,6 +53,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -60,7 +65,10 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@ -72,12 +80,14 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public abstract class PushSendJob extends SendJob {
private static final String TAG = Log.tag(PushSendJob.class);
private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1);
private static final long PUSH_CHALLENGE_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
protected PushSendJob(Job.Parameters parameters) {
super(parameters);
@ -100,6 +110,11 @@ public abstract class PushSendJob extends SendJob {
}
onPushSend();
if (SignalStore.rateLimit().needsRecaptcha()) {
Log.i(TAG, "Successfully sent message. Assuming reCAPTCHA no longer needed.");
SignalStore.rateLimit().onProofAccepted();
}
}
@Override
@ -125,15 +140,27 @@ public abstract class PushSendJob extends SendJob {
}
return exception instanceof IOException ||
exception instanceof RetryLaterException;
exception instanceof RetryLaterException ||
exception instanceof ProofRequiredException;
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (exception instanceof NonSuccessfulResponseCodeException) {
if (exception instanceof ProofRequiredException) {
long backoff = ((ProofRequiredException) exception).getRetryAfterSeconds();
warn(TAG, "[Proof Required] Retry-After is " + backoff + " seconds.");
if (backoff >= 0) {
return TimeUnit.SECONDS.toMillis(backoff);
}
} else if (exception instanceof NonSuccessfulResponseCodeException) {
if (((NonSuccessfulResponseCodeException) exception).is5xx()) {
return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getServerErrorMaxBackoff());
}
} else if (exception instanceof RetryLaterException) {
long backoff = ((RetryLaterException) exception).getBackoff();
if (backoff >= 0) {
return backoff;
}
}
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
@ -422,6 +449,81 @@ public abstract class PushSendJob extends SendJob {
return SignalServiceSyncMessage.forSentTranscript(transcript);
}
protected void handleProofRequiredException(@NonNull ProofRequiredException proofRequired, @Nullable Recipient recipient, long threadId, long messageId, boolean isMms)
throws ProofRequiredException, RetryLaterException
{
try {
if (proofRequired.getOptions().contains(ProofRequiredException.Option.PUSH_CHALLENGE)) {
ApplicationDependencies.getSignalServiceAccountManager().requestRateLimitPushChallenge();
log(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to " + PUSH_CHALLENGE_TIMEOUT + " ms.");
boolean success = new PushChallengeRequest(PUSH_CHALLENGE_TIMEOUT).blockUntilSuccess();
if (success) {
log(TAG, "Successfully responded to a push challenge. Retrying message send.");
throw new RetryLaterException(1);
} else {
warn(TAG, "Failed to respond to the push challenge in time. Falling back.");
}
}
} catch (NonSuccessfulResponseCodeException e) {
warn(TAG, "[Proof Required] Could not request a push challenge (" + e.getCode() + "). Falling back.", e);
} catch (IOException e) {
warn(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later.");
throw new RetryLaterException(e);
}
warn(TAG, "[Proof Required] Marking message as rate-limited. (id: " + messageId + ", mms: " + isMms + ", thread: " + threadId + ")");
if (isMms) {
DatabaseFactory.getMmsDatabase(context).markAsRateLimited(messageId);
} else {
DatabaseFactory.getSmsDatabase(context).markAsRateLimited(messageId);
}
if (proofRequired.getOptions().contains(ProofRequiredException.Option.RECAPTCHA)) {
log(TAG, "[Proof Required] ReCAPTCHA required.");
SignalStore.rateLimit().markNeedsRecaptcha(proofRequired.getToken());
if (recipient != null) {
ApplicationDependencies.getMessageNotifier().notifyProofRequired(context, recipient, threadId);
} else {
warn(TAG, "[Proof Required] No recipient! Couldn't notify.");
}
}
throw proofRequired;
}
protected abstract void onPushSend() throws Exception;
public static class PushChallengeRequest {
private final long timeout;
private final CountDownLatch latch;
private final EventBus eventBus;
private PushChallengeRequest(long timeout) {
this.timeout = timeout;
this.latch = new CountDownLatch(1);
this.eventBus = EventBus.getDefault();
}
public boolean blockUntilSuccess() {
eventBus.register(this);
try {
return latch.await(timeout, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Log.w(TAG, "[Proof Required] Interrupted?", e);
return false;
} finally {
eventBus.unregister(this);
}
}
@Subscribe(threadMode = ThreadMode.POSTING)
public void onSuccessReceived(SubmitRateLimitPushChallengeJob.SuccessEvent event) {
Log.i(TAG, "[Proof Required] Received a successful result!");
latch.countDown();
}
}
}

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@ -14,7 +13,6 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@ -31,12 +29,11 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public class PushTextSendJob extends PushSendJob {
@ -73,7 +70,7 @@ public class PushTextSendJob extends PushSendJob {
}
@Override
public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException {
public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException {
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageDatabase database = DatabaseFactory.getSmsDatabase(context);
SmsMessageRecord record = database.getSmsMessage(messageId);
@ -133,6 +130,8 @@ public class PushTextSendJob extends PushSendJob {
database.markAsSentFailed(record.getId());
database.markAsPush(record.getId());
RetrieveProfileJob.enqueue(recipientId);
} catch (ProofRequiredException e) {
handleProofRequiredException(e, record.getRecipient(), record.getThreadId(), messageId, false);
}
}
@ -187,6 +186,10 @@ public class PushTextSendJob extends PushSendJob {
}
}
public static long getMessageId(@NonNull Data data) {
return data.getLong(KEY_MESSAGE_ID);
}
public static class Factory implements Job.Factory<PushTextSendJob> {
@Override
public @NonNull PushTextSendJob create(@NonNull Parameters parameters, @NonNull Data data) {

View file

@ -47,6 +47,7 @@ public class RotateProfileKeyJob extends BaseJob {
ApplicationDependencies.getJobManager().add(new ProfileUploadJob());
ApplicationDependencies.getJobManager().add(new RefreshAttributesJob());
ApplicationDependencies.getJobManager().add(new MultiDeviceProfileKeyUpdateJob());
updateProfileKeyOnAllV2Groups();
}

View file

@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import java.util.concurrent.TimeUnit;
/**
* Send a push challenge token to the service as a way of proving that your device has FCM.
*/
public final class SubmitRateLimitPushChallengeJob extends BaseJob {
public static final String KEY = "SubmitRateLimitPushChallengeJob";
private static final String KEY_CHALLENGE = "challenge";
private final String challenge;
public SubmitRateLimitPushChallengeJob(@NonNull String challenge) {
this(new Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.HOURS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
challenge);
}
private SubmitRateLimitPushChallengeJob(@NonNull Parameters parameters, @NonNull String challenge) {
super(parameters);
this.challenge = challenge;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(KEY_CHALLENGE, challenge).build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
protected void onRun() throws Exception {
ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitPushChallenge(challenge);
SignalStore.rateLimit().onProofAccepted();
EventBus.getDefault().post(new SuccessEvent());
RateLimitUtil.retryAllRateLimitedMessages(context);
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof PushNetworkException;
}
@Override
public void onFailure() {
}
public static final class SuccessEvent {
}
public static class Factory implements Job.Factory<SubmitRateLimitPushChallengeJob> {
@Override
public @NonNull SubmitRateLimitPushChallengeJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new SubmitRateLimitPushChallengeJob(parameters, data.getString(KEY_CHALLENGE));
}
}
}

View file

@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
import java.util.Collections;
import java.util.List;
public final class RateLimitValues extends SignalStoreValues {
private static final String TAG = Log.tag(RateLimitValues.class);
private static final String KEY_NEEDS_RECAPTCHA = "ratelimit.needs_recaptcha";
private static final String KEY_CHALLENGE = "ratelimit.token";
RateLimitValues(@NonNull KeyValueStore store) {
super(store);
}
@Override
void onFirstEverAppLaunch() {
}
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Collections.emptyList();
}
/**
* @param challenge The token associated with the rate limit response.
*/
public void markNeedsRecaptcha(@NonNull String challenge) {
Log.i(TAG, "markNeedsRecaptcha()");
putBoolean(KEY_NEEDS_RECAPTCHA, true);
putString(KEY_CHALLENGE, challenge);
EventBus.getDefault().post(new RecaptchaRequiredEvent());
}
public void onProofAccepted() {
Log.i(TAG, "onProofAccepted()", new Throwable());
putBoolean(KEY_NEEDS_RECAPTCHA, false);
remove(KEY_CHALLENGE);
}
public boolean needsRecaptcha() {
return getBoolean(KEY_NEEDS_RECAPTCHA, false);
}
public @NonNull String getChallenge() {
return getString(KEY_CHALLENGE, "");
}
}

View file

@ -35,6 +35,7 @@ public final class SignalStore {
private final WallpaperValues wallpaperValues;
private final PaymentsValues paymentsValues;
private final ProxyValues proxyValues;
private final RateLimitValues rateLimitValues;
private SignalStore() {
this.store = new KeyValueStore(ApplicationDependencies.getApplication());
@ -55,6 +56,7 @@ public final class SignalStore {
this.wallpaperValues = new WallpaperValues(store);
this.paymentsValues = new PaymentsValues(store);
this.proxyValues = new ProxyValues(store);
this.rateLimitValues = new RateLimitValues(store);
}
public static void onFirstEverAppLaunch() {
@ -75,6 +77,7 @@ public final class SignalStore {
wallpaper().onFirstEverAppLaunch();
paymentsValues().onFirstEverAppLaunch();
proxy().onFirstEverAppLaunch();
rateLimit().onFirstEverAppLaunch();
}
public static List<String> getKeysToIncludeInBackup() {
@ -96,6 +99,7 @@ public final class SignalStore {
keys.addAll(wallpaper().getKeysToIncludeInBackup());
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
keys.addAll(proxy().getKeysToIncludeInBackup());
keys.addAll(rateLimit().getKeysToIncludeInBackup());
return keys;
}
@ -176,6 +180,10 @@ public final class SignalStore {
return INSTANCE.proxyValues;
}
public static @NonNull RateLimitValues rateLimit() {
return INSTANCE.rateLimitValues;
}
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore());
}

View file

@ -67,4 +67,8 @@ abstract class SignalStoreValues {
void putString(@NonNull String key, String value) {
store.beginWrite().putString(key, value).apply();
}
void remove(@NonNull String key) {
store.beginWrite().remove(key);
}
}

View file

@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
@ -944,6 +945,11 @@ public final class MessageContentProcessor {
ApplicationDependencies.getMessageNotifier().updateNotification(context);
}
if (SignalStore.rateLimit().needsRecaptcha()) {
Log.i(TAG, "Got a sent transcript while in reCAPTCHA mode. Assuming we're good to message again.");
RateLimitUtil.retryAllRateLimitedMessages(context);
}
ApplicationDependencies.getMessageNotifier().setLastDesktopActivityTimestamp(message.getTimestamp());
} catch (MmsException e) {
throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice());

View file

@ -23,6 +23,7 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.BitmapFactory;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.Ringtone;
@ -131,7 +132,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
@Override
public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) {
public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
if (visibleThread == threadId) {
sendInThreadNotification(context, recipient);
} else {
@ -145,6 +146,15 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
}
@Override
public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
if (visibleThread == threadId) {
sendInThreadNotification(context, recipient);
} else {
Log.w(TAG, "[Proof Required] Not notifying on old notifier.");
}
}
@Override
public void cancelDelayedNotifications() {
executor.cancel();

View file

@ -16,7 +16,8 @@ public interface MessageNotifier {
long getVisibleThread();
void clearVisibleThread();
void setLastDesktopActivityTimestamp(long timestamp);
void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId);
void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId);
void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId);
void cancelDelayedNotifications();
void updateNotification(@NonNull Context context);
void updateNotification(@NonNull Context context, long threadId);

View file

@ -53,10 +53,15 @@ public class OptimizedMessageNotifier implements MessageNotifier {
}
@Override
public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) {
public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
getNotifier().notifyMessageDeliveryFailed(context, recipient, threadId);
}
@Override
public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
getNotifier().notifyProofRequired(context, recipient, threadId);
}
@Override
public void cancelDelayedNotifications() {
getNotifier().cancelDelayedNotifications();

View file

@ -76,6 +76,10 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
NotificationFactory.notifyMessageDeliveryFailed(context, recipient, threadId, visibleThread)
}
override fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long) {
NotificationFactory.notifyProofRequired(context, recipient, threadId, visibleThread)
}
override fun cancelDelayedNotifications() {
executor.cancel()
}

View file

@ -315,6 +315,33 @@ object NotificationFactory {
NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build())
}
fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long, visibleThread: Long) {
if (threadId == visibleThread) {
notifyInThread(context, recipient, 0)
return
}
val intent: Intent = ConversationIntents.createBuilder(context, recipient.id, threadId)
.build()
.makeUniqueToPreventMerging()
val builder: NotificationBuilder = NotificationBuilder.create(context)
builder.apply {
setSmallIcon(R.drawable.ic_notification)
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_info_outline))
setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_paused))
setContentText(context.getString(R.string.MessageNotifier_verify_to_continue_messaging_on_signal))
setContentIntent(PendingIntent.getActivity(context, 0, intent, 0))
setOnlyAlertOnce(true)
setAutoCancel(true)
setAlarms(recipient)
setChannelId(NotificationChannels.FAILURES)
}
NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build())
}
private fun NotificationManagerCompat.safelyNotify(context: Context, threadRecipient: Recipient?, notificationId: Int, notification: Notification) {
try {
notify(notificationId, notification)

View file

@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.ratelimit;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
import org.thoughtcrime.securesms.jobs.PushTextSendJob;
import java.util.Set;
public final class RateLimitUtil {
private static final String TAG = Log.tag(RateLimitUtil.class);
private RateLimitUtil() {}
/**
* Forces a retry of all rate limited messages by editing jobs that are in the queue.
*/
@WorkerThread
public static void retryAllRateLimitedMessages(@NonNull Context context) {
Set<Long> sms = DatabaseFactory.getSmsDatabase(context).getAllRateLimitedMessageIds();
Set<Long> mms = DatabaseFactory.getMmsDatabase(context).getAllRateLimitedMessageIds();
if (sms.isEmpty() && mms.isEmpty()) {
return;
}
Log.i(TAG, "Retrying " + sms.size() + " sms records and " + mms.size() + " mms records.");
DatabaseFactory.getSmsDatabase(context).clearRateLimitStatus(sms);
DatabaseFactory.getMmsDatabase(context).clearRateLimitStatus(mms);
ApplicationDependencies.getJobManager().update((job, serializer) -> {
Data data = serializer.deserialize(job.getSerializedData());
if (job.getFactoryKey().equals(PushTextSendJob.KEY) && sms.contains(PushTextSendJob.getMessageId(data))) {
return job.withNextRunAttemptTime(System.currentTimeMillis());
} else if (job.getFactoryKey().equals(PushMediaSendJob.KEY) && mms.contains(PushMediaSendJob.getMessageId(data))) {
return job.withNextRunAttemptTime(System.currentTimeMillis());
} else if (job.getFactoryKey().equals(PushGroupSendJob.KEY) && mms.contains(PushGroupSendJob.getMessageId(data))) {
return job.withNextRunAttemptTime(System.currentTimeMillis());
} else {
return job;
}
});
}
}

View file

@ -0,0 +1,144 @@
package org.thoughtcrime.securesms.ratelimit;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.view.MenuItem;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import java.io.IOException;
/**
* Asks the user to solve a reCAPTCHA. If successful, triggers resends of all relevant message jobs.
*/
public class RecaptchaProofActivity extends PassphraseRequiredActivity {
private static final String TAG = Log.tag(RecaptchaProofActivity.class);
private static final String RECAPTCHA_SCHEME = "signalcaptcha://";
private final DynamicTheme dynamicTheme = new DynamicTheme();
public static @NonNull Intent getIntent(@NonNull Context context) {
return new Intent(context, RecaptchaProofActivity.class);
}
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
}
@Override
@SuppressLint("SetJavaScriptEnabled")
protected void onCreate(Bundle savedInstanceState, boolean ready) {
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.recaptcha_activity);
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
requireSupportActionBar().setTitle(R.string.RecaptchaProofActivity_complete_verification);
WebView webView = findViewById(R.id.recaptcha_webview);
webView.getSettings().setJavaScriptEnabled(true);
webView.clearCache(true);
webView.setBackgroundColor(Color.TRANSPARENT);
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url != null && url.startsWith(RECAPTCHA_SCHEME)) {
handleToken(url.substring(RECAPTCHA_SCHEME.length()));
return true;
}
return false;
}
});
webView.loadUrl(BuildConfig.RECAPTCHA_PROOF_URL);
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
private void handleToken(@NonNull String token) {
SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(this, 1000, 500);
SimpleTask.run(() -> {
String challenge = SignalStore.rateLimit().getChallenge();
if (Util.isEmpty(challenge)) {
Log.w(TAG, "No challenge available?");
return new TokenResult(true, false);
}
try {
for (int i = 0; i < 3; i++) {
try {
ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitRecaptchaChallenge(challenge, token);
RateLimitUtil.retryAllRateLimitedMessages(this);
Log.i(TAG, "Successfully completed reCAPTCHA.");
return new TokenResult(true, true);
} catch (PushNetworkException e) {
Log.w(TAG, "Network error during submission. Retrying.", e);
}
}
} catch (IOException e) {
Log.w(TAG, "Terminal failure during submission. Will clear state. May get a 428 later.", e);
return new TokenResult(true, false);
}
return new TokenResult(false, false);
}, result -> {
dialog.dismiss();
if (result.clearState) {
Log.i(TAG, "Considering the response sufficient to clear the slate.");
SignalStore.rateLimit().onProofAccepted();
}
if (!result.success) {
Log.w(TAG, "Response was not a true success.");
Toast.makeText(this, R.string.RecaptchaProofActivity_failed_to_submit, Toast.LENGTH_LONG).show();
}
finish();
});
}
private static final class TokenResult {
final boolean clearState;
final boolean success;
private TokenResult(boolean clearState, boolean success) {
this.clearState = clearState;
this.success = success;
}
}
}

View file

@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.ratelimit;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
/**
* A bottom sheet to be shown when we need to prompt the user to fill out a reCAPTCHA.
*/
public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFragment {
private static final String TAG = Log.tag(RecaptchaProofBottomSheetFragment.class);
public static void show(@NonNull FragmentManager manager) {
new RecaptchaProofBottomSheetFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
setStyle(DialogFragment.STYLE_NORMAL, R.style.Signal_DayNight_BottomSheet_Rounded);
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.recaptcha_required_bottom_sheet, container, false);
view.findViewById(R.id.recaptcha_sheet_ok_button).setOnClickListener(v -> {
dismissAllowingStateLoss();
startActivity(RecaptchaProofActivity.getIntent(requireContext()));
});
return view;
}
@Override
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
if (manager.findFragmentByTag(tag) == null) {
BottomSheetUtil.show(manager, tag, this);
} else {
Log.i(TAG, "Ignoring repeat show.");
}
}
}

View file

@ -0,0 +1,4 @@
package org.thoughtcrime.securesms.ratelimit;
public final class RecaptchaRequiredEvent {
}

View file

@ -91,7 +91,7 @@ public final class PushChallengeRequest {
eventBus.register(this);
try {
accountManager.requestPushChallenge(fcmToken, e164number);
accountManager.requestRegistrationPushChallenge(fcmToken, e164number);
latch.await(timeoutMs, TimeUnit.MILLISECONDS);

View file

@ -1,9 +1,30 @@
package org.thoughtcrime.securesms.transport;
public class RetryLaterException extends Exception {
public RetryLaterException() {}
private final long backoff;
public RetryLaterException() {
this(null, -1);
}
public RetryLaterException(long backoff) {
this(null, backoff);
}
public RetryLaterException(Exception e) {
this(e, -1);
}
public RetryLaterException(Exception e, long backoff) {
super(e);
this.backoff = backoff;
}
/**
* @return The amount of time to wait before retrying again, or -1 if none is specified.
*/
public long getBackoff() {
return backoff;
}
}

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.util;
import android.app.Application;
import android.os.Build;
import android.text.TextUtils;

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/recaptcha_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="16dp" />
</ScrollView>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:theme="@style/Theme.Signal.RoundedBottomSheet.Light">
<TextView
android:id="@+id/recaptcha_sheet_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="20dp"
android:text="@string/RecaptchaRequiredBottomSheetFragment_verify_to_continue_messaging"
android:textAppearance="@style/TextAppearance.Signal.Title2.Bold"
android:textColor="@color/signal_text_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/recaptcha_sheet_paragraph_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:text="@string/RecaptchaRequiredBottomSheetFragment_to_help_prevent_spam_on_signal"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_title" />
<TextView
android:id="@+id/recaptcha_sheet_paragraph_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:text="@string/RecaptchaRequiredBottomSheetFragment_after_verifying_you_can_continue_messaging"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_paragraph_1" />
<Button
android:id="@+id/recaptcha_sheet_ok_button"
style="@style/Button.Primary"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginStart="32dp"
android:layout_marginTop="38dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="20dp"
android:text="@android:string/ok"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_paragraph_2"
app:layout_constraintVertical_bias="1" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -202,6 +202,7 @@
<string name="ConversationItem_error_network_not_delivered">Send failed</string>
<string name="ConversationItem_received_key_exchange_message_tap_to_process">Received key exchange message, tap to process.</string>
<string name="ConversationItem_group_action_left">%1$s has left the group.</string>
<string name="ConversationItem_send_paused">Send paused</string>
<string name="ConversationItem_click_to_approve_unencrypted">Send failed, tap for unsecured fallback</string>
<string name="ConversationItem_click_to_approve_unencrypted_sms_dialog_title">Fallback to unencrypted SMS?</string>
<string name="ConversationItem_click_to_approve_unencrypted_mms_dialog_title">Fallback to unencrypted MMS?</string>
@ -1354,6 +1355,11 @@
<!-- ReactionsRecipientAdapter -->
<string name="ReactionsRecipientAdapter_you">You</string>
<!-- RecaptchaRequiredBottomSheetFragment -->
<string name="RecaptchaRequiredBottomSheetFragment_verify_to_continue_messaging">Verify to continue messaging</string>
<string name="RecaptchaRequiredBottomSheetFragment_to_help_prevent_spam_on_signal">To help prevent spam on Signal, please complete verification.</string>
<string name="RecaptchaRequiredBottomSheetFragment_after_verifying_you_can_continue_messaging">After verifying, you can continue messaging. Any paused messages will automatically be sent.</string>
<!-- RecipientPreferencesActivity -->
<string name="RecipientPreferenceActivity_block">Block</string>
<string name="RecipientPreferenceActivity_unblock">Unblock</string>
@ -1422,6 +1428,9 @@
<string name="ProxyBottomSheetFragment_use_proxy">Use proxy</string>
<string name="ProxyBottomSheetFragment_successfully_connected_to_proxy">Successfully connected to proxy.</string>
<!-- RecaptchaProofActivity -->
<string name="RecaptchaProofActivity_failed_to_submit">Failed to submit</string>
<string name="RecaptchaProofActivity_complete_verification">Complete verification</string>
<!-- RegistrationActivity -->
<string name="RegistrationActivity_select_your_country">Select your country</string>
@ -1694,6 +1703,8 @@
<string name="MessageNotifier_message_delivery_failed">Message delivery failed.</string>
<string name="MessageNotifier_failed_to_deliver_message">Failed to deliver message.</string>
<string name="MessageNotifier_error_delivering_message">Error delivering message.</string>
<string name="MessageNotifier_message_delivery_paused">Message delivery paused.</string>
<string name="MessageNotifier_verify_to_continue_messaging_on_signal">Verify to continue messaging on Signal.</string>
<string name="MessageNotifier_mark_all_as_read">Mark all as read</string>
<string name="MessageNotifier_mark_read">Mark read</string>
<string name="MessageNotifier_turn_off_these_notifications">Turn off these notifications</string>

View file

@ -56,14 +56,14 @@ public final class PushChallengeRequestTest {
doAnswer(invocation -> {
AsyncTask.execute(() -> PushChallengeRequest.postChallengeResponse("CHALLENGE"));
return null;
}).when(signal).requestPushChallenge("token", "+123456");
}).when(signal).requestRegistrationPushChallenge("token", "+123456");
long startTime = System.currentTimeMillis();
Optional<String> challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L);
long duration = System.currentTimeMillis() - startTime;
assertThat(duration, lessThan(500L));
verify(signal).requestPushChallenge("token", "+123456");
verify(signal).requestRegistrationPushChallenge("token", "+123456");
verifyNoMoreInteractions(signal);
assertTrue(challenge.isPresent());
@ -95,7 +95,7 @@ public final class PushChallengeRequestTest {
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());
doThrow(new IOException()).when(signal).requestRegistrationPushChallenge(any(), any());
Optional<String> challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L);

View file

@ -198,7 +198,7 @@ public class SignalServiceAccountManager {
* @param e164number The number to associate it with.
* @throws IOException
*/
public void requestPushChallenge(String gcmRegistrationId, String e164number) throws IOException {
public void requestRegistrationPushChallenge(String gcmRegistrationId, String e164number) throws IOException {
this.pushServiceSocket.requestPushChallenge(gcmRegistrationId, e164number);
}
@ -711,6 +711,18 @@ public class SignalServiceAccountManager {
this.pushServiceSocket.deleteAccount();
}
public void requestRateLimitPushChallenge() throws IOException {
this.pushServiceSocket.requestRateLimitPushChallenge();
}
public void submitRateLimitPushChallenge(String challenge) throws IOException {
this.pushServiceSocket.submitRateLimitPushChallenge(challenge);
}
public void submitRateLimitRecaptchaChallenge(String challenge, String recaptchaToken) throws IOException {
this.pushServiceSocket.submitRateLimitRecaptchaChallenge(challenge, recaptchaToken);
}
public void setSoTimeoutMillis(long soTimeoutMillis) {
this.pushServiceSocket.setSoTimeoutMillis(soTimeoutMillis);
}

View file

@ -8,7 +8,6 @@ package org.whispersystems.signalservice.api;
import com.google.protobuf.ByteString;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
@ -26,12 +25,14 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes;
import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttributes;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import org.whispersystems.signalservice.internal.push.SendMessageResponse;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.util.Util;
@ -196,6 +197,12 @@ public class SignalServiceMessagePipe {
return FutureTransformers.map(response, value -> {
if (value.getStatus() == 404) {
throw new UnregisteredUserException(list.getDestination(), new NotFoundException("not found"));
} else if (value.getStatus() == 428) {
ProofRequiredResponse proofResponse = JsonUtil.fromJson(value.getBody(), ProofRequiredResponse.class);
String retryAfterRaw = value.getHeader("Retry-After");
long retryAfter = Util.parseInt(retryAfterRaw, -1);
throw new ProofRequiredException(proofResponse, retryAfter);
} else if (value.getStatus() == 508) {
throw new ServerRejectedException();
} else if (value.getStatus() < 200 || value.getStatus() >= 300) {

View file

@ -55,6 +55,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
@ -68,6 +69,7 @@ import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttribut
import org.whispersystems.signalservice.internal.push.MismatchedDevices;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import org.whispersystems.signalservice.internal.push.ProvisioningProtos;
import org.whispersystems.signalservice.internal.push.PushAttachmentData;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
@ -98,6 +100,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
@ -1424,6 +1427,9 @@ public class SignalServiceMessageSender {
} else if (e.getCause() instanceof ServerRejectedException) {
Log.w(TAG, e);
throw ((ServerRejectedException) e.getCause());
} else if (e.getCause() instanceof ProofRequiredException) {
Log.w(TAG, e);
results.add(SendMessageResult.proofRequiredFailure(recipient, (ProofRequiredException) e.getCause()));
} else {
throw new IOException(e);
}

View file

@ -3,6 +3,7 @@ package org.whispersystems.signalservice.api.messages;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
public class SendMessageResult {
@ -11,21 +12,26 @@ public class SendMessageResult {
private final boolean networkFailure;
private final boolean unregisteredFailure;
private final IdentityFailure identityFailure;
private final ProofRequiredException proofRequiredFailure;
public static SendMessageResult success(SignalServiceAddress address, boolean unidentified, boolean needsSync, long duration) {
return new SendMessageResult(address, new Success(unidentified, needsSync, duration), false, false, null);
return new SendMessageResult(address, new Success(unidentified, needsSync, duration), false, false, null, null);
}
public static SendMessageResult networkFailure(SignalServiceAddress address) {
return new SendMessageResult(address, null, true, false, null);
return new SendMessageResult(address, null, true, false, null, null);
}
public static SendMessageResult unregisteredFailure(SignalServiceAddress address) {
return new SendMessageResult(address, null, false, true, null);
return new SendMessageResult(address, null, false, true, null, null);
}
public static SendMessageResult identityFailure(SignalServiceAddress address, IdentityKey identityKey) {
return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey));
return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey), null);
}
public static SendMessageResult proofRequiredFailure(SignalServiceAddress address, ProofRequiredException proofRequiredException) {
return new SendMessageResult(address, null, false, false, null, proofRequiredException);
}
public SignalServiceAddress getAddress() {
@ -37,7 +43,7 @@ public class SendMessageResult {
}
public boolean isNetworkFailure() {
return networkFailure;
return networkFailure || proofRequiredFailure != null;
}
public boolean isUnregisteredFailure() {
@ -48,12 +54,23 @@ public class SendMessageResult {
return identityFailure;
}
private SendMessageResult(SignalServiceAddress address, Success success, boolean networkFailure, boolean unregisteredFailure, IdentityFailure identityFailure) {
public ProofRequiredException getProofRequiredFailure() {
return proofRequiredFailure;
}
private SendMessageResult(SignalServiceAddress address,
Success success,
boolean networkFailure,
boolean unregisteredFailure,
IdentityFailure identityFailure,
ProofRequiredException proofRequiredFailure)
{
this.address = address;
this.success = success;
this.networkFailure = networkFailure;
this.unregisteredFailure = unregisteredFailure;
this.identityFailure = identityFailure;
this.proofRequiredFailure = proofRequiredFailure;
}
public static class Success {

View file

@ -0,0 +1,63 @@
package org.whispersystems.signalservice.api.push.exceptions;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Thrown when rate-limited by the server and proof of humanity is required to continue messaging.
*/
public class ProofRequiredException extends NonSuccessfulResponseCodeException {
private static final String TAG = "ProofRequiredRateLimit";
private final String token;
private final Set<Option> options;
private final long retryAfterSeconds;
public ProofRequiredException(ProofRequiredResponse response, long retryAfterSeconds) {
super(428);
this.token = response.getToken();
this.options = parseOptions(response.getOptions());
this.retryAfterSeconds = retryAfterSeconds;
}
public String getToken() {
return token;
}
public Set<Option> getOptions() {
return options;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
}
private static Set<Option> parseOptions(List<String> rawOptions) {
Set<Option> options = new HashSet<>(rawOptions.size());
for (String raw : rawOptions) {
switch (raw) {
case "recaptcha":
options.add(Option.RECAPTCHA);
break;
case "pushChallenge":
options.add(Option.PUSH_CHALLENGE);
break;
default:
Log.w(TAG, "Unrecognized challenge option: " + raw);
break;
}
}
return options;
}
public enum Option {
RECAPTCHA, PUSH_CHALLENGE
}
}

View file

@ -0,0 +1,24 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class ProofRequiredResponse {
@JsonProperty
public String token;
@JsonProperty
public List<String> options;
public ProofRequiredResponse() {}
public String getToken() {
return token;
}
public List<String> getOptions() {
return options;
}
}

View file

@ -60,6 +60,7 @@ import org.whispersystems.signalservice.api.push.exceptions.MissingConfiguration
import org.whispersystems.signalservice.api.push.exceptions.NoContentException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.RangeException;
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
@ -221,6 +222,9 @@ public class PushServiceSocket {
private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions";
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
@ -770,6 +774,20 @@ public class PushServiceSocket {
makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null);
}
public void requestRateLimitPushChallenge() throws IOException {
makeServiceRequest(REQUEST_RATE_LIMIT_PUSH_CHALLENGE, "POST", "");
}
public void submitRateLimitPushChallenge(String challenge) throws IOException {
String payload = JsonUtil.toJson(new SubmitPushChallengePayload(challenge));
makeServiceRequest(SUBMIT_RATE_LIMIT_CHALLENGE, "PUT", payload);
}
public void submitRateLimitRecaptchaChallenge(String challenge, String recaptchaToken) throws IOException {
String payload = JsonUtil.toJson(new SubmitRecaptchaChallengePayload(challenge, recaptchaToken));
makeServiceRequest(SUBMIT_RATE_LIMIT_CHALLENGE, "PUT", payload);
}
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
@ -1468,6 +1486,13 @@ public class PushServiceSocket {
throw new LockedException(accountLockFailure.length,
accountLockFailure.timeRemaining,
basicStorageCredentials);
case 428:
ProofRequiredResponse proofRequiredResponse = readResponseJson(response, ProofRequiredResponse.class);
String retryAfterRaw = response.header("Retry-After");
long retryAfter = Util.parseInt(retryAfterRaw, -1);
throw new ProofRequiredException(proofRequiredResponse, retryAfter);
case 499:
throw new DeprecatedVersionException();

View file

@ -0,0 +1,19 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
class SubmitPushChallengePayload {
@JsonProperty
private String type;
@JsonProperty
private String challenge;
public SubmitPushChallengePayload() {}
public SubmitPushChallengePayload(String challenge) {
this.type = "rateLimitPushChallenge";
this.challenge = challenge;
}
}

View file

@ -0,0 +1,23 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
class SubmitRecaptchaChallengePayload {
@JsonProperty
private String type;
@JsonProperty
private String token;
@JsonProperty
private String captcha;
public SubmitRecaptchaChallengePayload() {}
public SubmitRecaptchaChallengePayload(String challenge, String recaptchaToken) {
this.type = "recaptcha";
this.token = challenge;
this.captcha = recaptchaToken;
}
}

View file

@ -150,4 +150,11 @@ public class Util {
return Collections.unmodifiableList(Arrays.asList(elements.clone()));
}
public static int parseInt(String integer, int defaultValue) {
try {
return Integer.parseInt(integer);
} catch (NumberFormatException e) {
return defaultValue;
}
}
}

View file

@ -255,7 +255,8 @@ public class WebSocketConnection extends WebSocketListener {
OutgoingRequest listener = outgoingRequests.get(message.getResponse().getId());
if (listener != null) {
listener.getResponseFuture().set(new WebsocketResponse(message.getResponse().getStatus(),
new String(message.getResponse().getBody().toByteArray())));
new String(message.getResponse().getBody().toByteArray()),
message.getResponse().getHeadersList()));
}
}

View file

@ -1,12 +1,20 @@
package org.whispersystems.signalservice.internal.websocket;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class WebsocketResponse {
private final int status;
private final String body;
private final Map<String, String> headers;
WebsocketResponse(int status, String body) {
WebsocketResponse(int status, String body, List<String> headers) {
this.status = status;
this.body = body;
this.headers = parseHeaders(headers);
}
public int getStatus() {
@ -16,4 +24,27 @@ public class WebsocketResponse {
public String getBody() {
return body;
}
public String getHeader(String key) {
return headers.get(Preconditions.checkNotNull(key.toLowerCase()));
}
private static Map<String, String> parseHeaders(List<String> rawHeaders) {
Map<String, String> headers = new HashMap<>(rawHeaders.size());
for (String raw : rawHeaders) {
if (raw != null && raw.length() > 0) {
int colonIndex = raw.indexOf(":");
if (colonIndex > 0 && colonIndex < raw.length() - 1) {
String key = raw.substring(0, colonIndex).trim().toLowerCase();
String value = raw.substring(colonIndex + 1).trim();
headers.put(key, value);
}
}
}
return headers;
}
}