From 2791790bf5854bdff11615e359e8cb49a7d74f10 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 2 Jul 2020 10:38:52 -0700 Subject: [PATCH] Implement new CDS changes. --- app/build.gradle | 2 +- .../contacts/sync/ContactDiscoveryV1.java | 58 +++ .../contacts/sync/ContactDiscoveryV2.java | 89 ++++ .../contacts/sync/DirectoryHelper.java | 372 +++++++++++++++- .../contacts/sync/DirectoryHelperV1.java | 401 ------------------ .../contacts/sync/FuzzyPhoneNumberHelper.java | 45 ++ .../securesms/database/RecipientDatabase.java | 22 +- .../securesms/util/FeatureFlags.java | 6 + .../thoughtcrime/securesms/util/SetUtil.java | 10 +- .../sync/FuzzyPhoneNumberHelperTest.java | 72 ++++ .../securesms/testutil/TestHelpers.java | 19 + .../securesms/util/FeatureFlagsTest.java | 18 +- .../api/SignalServiceAccountManager.java | 69 ++- .../signalservice/api/crypto/CryptoUtil.java | 10 + .../internal/contacts/crypto/AESCipher.java | 4 +- .../crypto/ContactDiscoveryCipher.java | 63 ++- .../contacts/entities/DiscoveryRequest.java | 24 +- .../contacts/entities/DiscoveryResponse.java | 9 +- .../MultiRemoteAttestationResponse.java | 16 + .../contacts/entities/QueryEnvelope.java | 27 ++ .../internal/push/PushServiceSocket.java | 18 - .../internal/push/RemoteAttestationUtil.java | 115 +++-- 22 files changed, 908 insertions(+), 561 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV1.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/MultiRemoteAttestationResponse.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/QueryEnvelope.java diff --git a/app/build.gradle b/app/build.gradle index 9ed29d137f..1ab4df44c3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,7 +197,7 @@ android { buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"" buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\"" buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"" - buildConfigField "String", "CDS_MRENCLAVE", "\"ba4ebb438bc07713819ee6c98d94037747006d7df63fc9e44d2d6f1fec962a79\"" + buildConfigField "String", "CDS_MRENCLAVE", "\"b657cad56d518827b0938949bb1e5727a9a4db358dd6a88e55e710a89ffa50bd\"" buildConfigField "String", "KBS_ENCLAVE_NAME", "\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\"" buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"" buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\"" diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV1.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV1.java new file mode 100644 index 0000000000..7f6bb47e1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV1.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.contacts.sync; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper.DirectoryResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.SetUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.ContactTokenDetails; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +class ContactDiscoveryV1 { + + private static final String TAG = ContactDiscoveryV1.class.getSimpleName(); + + static @NonNull DirectoryResult getDirectoryResult(@NonNull Set databaseNumbers, + @NonNull Set systemNumbers) + throws IOException + { + Set allNumbers = SetUtil.union(databaseNumbers, systemNumbers); + FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(allNumbers, databaseNumbers); + List activeTokens = getTokens(inputResult.getNumbers()); + Set activeNumbers = Stream.of(activeTokens).map(ContactTokenDetails::getNumber).collect(Collectors.toSet()); + FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(activeNumbers, inputResult); + HashMap uuids = new HashMap<>(); + + for (String number : outputResult.getNumbers()) { + uuids.put(number, null); + } + + return new DirectoryResult(uuids, outputResult.getRewrites()); + } + + static @NonNull DirectoryResult getDirectoryResult(@NonNull String number) throws IOException { + return getDirectoryResult(Collections.singleton(number), Collections.singleton(number)); + } + + private static @NonNull List getTokens(@NonNull Set numbers) throws IOException { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + + if (numbers.size() == 1) { + Optional details = accountManager.getContact(numbers.iterator().next()); + return details.isPresent() ? Collections.singletonList(details.get()) : Collections.emptyList(); + } else { + return accountManager.getContacts(numbers); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java new file mode 100644 index 0000000000..fdc6b3aab0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.contacts.sync; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper.DirectoryResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.push.IasTrustStore; +import org.thoughtcrime.securesms.util.SetUtil; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.internal.contacts.crypto.Quote; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +class ContactDiscoveryV2 { + + private static final String TAG = Log.tag(ContactDiscoveryV2.class); + + @WorkerThread + static DirectoryResult getDirectoryResult(@NonNull Context context, + @NonNull Set databaseNumbers, + @NonNull Set systemNumbers) + throws IOException + { + Set allNumbers = SetUtil.union(databaseNumbers, systemNumbers); + FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(allNumbers, databaseNumbers); + Set sanitizedNumbers = sanitizeNumbers(inputResult.getNumbers()); + + + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + KeyStore iasKeyStore = getIasKeyStore(context); + + try { + Map results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE); + FuzzyPhoneNumberHelper.OutputResultV2 outputResult = FuzzyPhoneNumberHelper.generateOutputV2(results, inputResult); + + return new DirectoryResult(outputResult.getNumbers(), outputResult.getRewrites()); + } catch (SignatureException | UnauthenticatedQuoteException | UnauthenticatedResponseException | Quote.InvalidQuoteFormatException e) { + Log.w(TAG, "Attestation error.", e); + throw new IOException(e); + } + } + + static @NonNull DirectoryResult getDirectoryResult(@NonNull Context context, @NonNull String number) throws IOException { + return getDirectoryResult(context, Collections.singleton(number), Collections.singleton(number)); + } + + private static Set sanitizeNumbers(@NonNull Set numbers) { + return Stream.of(numbers).filter(number -> { + try { + return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0; + } catch (NumberFormatException e) { + return false; + } + }).collect(Collectors.toSet()); + } + + private static KeyStore getIasKeyStore(@NonNull Context context) { + try { + TrustStore contactTrustStore = new IasTrustStore(context); + + KeyStore keyStore = KeyStore.getInstance("BKS"); + keyStore.load(contactTrustStore.getKeyStoreInputStream(), contactTrustStore.getKeyStorePassword().toCharArray()); + + return keyStore; + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index 020cb571d9..751e9dbd57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -1,32 +1,153 @@ package org.thoughtcrime.securesms.contacts.sync; +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.text.TextUtils; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.thoughtcrime.securesms.contacts.ContactsDatabase; +import org.thoughtcrime.securesms.crypto.SessionUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.IncomingJoinedMessage; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.IOException; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +/** + * Manages all the stuff around determining if a user is registered or not. + */ public class DirectoryHelper { private static final String TAG = Log.tag(DirectoryHelper.class); @WorkerThread public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException { - if (FeatureFlags.uuids()) { - // TODO [greyson] Create a DirectoryHelperV2 when appropriate. - DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); + if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) { + Log.i(TAG, "Have not yet set our own local number. Skipping."); + return; + } + + if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) { + Log.i(TAG, "No contact permissions. Skipping."); + return; + } + + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + Set databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers()); + Set systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context)); + Set allNumbers = SetUtil.union(databaseNumbers, systemNumbers); + + DirectoryResult result; + + if (FeatureFlags.cds()) { + result = ContactDiscoveryV2.getDirectoryResult(context, databaseNumbers, systemNumbers); } else { - DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); + result = ContactDiscoveryV1.getDirectoryResult(databaseNumbers, systemNumbers); + } + + if (result.getNumberRewrites().size() > 0) { + Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers."); + recipientDatabase.updatePhoneNumbers(result.getNumberRewrites()); + } + + HashMap uuidMap = new HashMap<>(); + + // TODO [greyson] [cds] Probably want to do this in a DB transaction to prevent concurrent operations + for (Map.Entry entry : result.getRegisteredNumbers().entrySet()) { + // TODO [greyson] [cds] This is where we'll have to do record merging + String e164 = entry.getKey(); + UUID uuid = entry.getValue(); + Optional uuidEntry = uuid != null ? recipientDatabase.getByUuid(uuid) : Optional.absent(); + + // TODO [greyson] [cds] Handle phone numbers changing, possibly conflicting + if (uuidEntry.isPresent()) { + recipientDatabase.setPhoneNumber(uuidEntry.get(), e164); + } + + RecipientId id = uuidEntry.isPresent() ? uuidEntry.get() : recipientDatabase.getOrInsertFromE164(e164); + + uuidMap.put(id, uuid != null ? uuid.toString() : null); + } + + Set activeNumbers = result.getRegisteredNumbers().keySet(); + Set activeIds = uuidMap.keySet(); + Set inactiveIds = Stream.of(allNumbers) + .filterNot(activeNumbers::contains) + .filterNot(n -> result.getNumberRewrites().containsKey(n)) + .map(recipientDatabase::getOrInsertFromE164) + .collect(Collectors.toSet()); + + recipientDatabase.bulkUpdatedRegisteredStatus(uuidMap, inactiveIds); + + updateContactsDatabase(context, activeIds, true, result.getNumberRewrites()); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); + } + + if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context) && notifyOfNewUsers) { + Set existingSignalIds = new HashSet<>(recipientDatabase.getRegistered()); + Set existingSystemIds = new HashSet<>(recipientDatabase.getSystemContacts()); + Set newlyActiveIds = new HashSet<>(activeIds); + + newlyActiveIds.removeAll(existingSignalIds); + newlyActiveIds.retainAll(existingSystemIds); + + notifyNewUsers(context, newlyActiveIds); + } else { + TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true); } StorageSyncHelper.scheduleSyncForDataChange(); @@ -34,20 +155,249 @@ public class DirectoryHelper { @WorkerThread public static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException { - RegisteredState originalRegisteredState = recipient.resolve().getRegistered(); - RegisteredState newRegisteredState = null; + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RegisteredState originalRegisteredState = recipient.resolve().getRegistered(); + RegisteredState newRegisteredState = null; - if (FeatureFlags.uuids()) { - // TODO [greyson] Create a DirectoryHelperV2 when appropriate. - newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers); - } else { - newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers); + if (recipient.getUuid().isPresent()) { + boolean isRegistered = isUuidRegistered(context, recipient); + if (isRegistered) { + recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get()); + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + + return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED; } + if (!recipient.getE164().isPresent()) { + Log.w(TAG, "No UUID or E164?"); + return RegisteredState.NOT_REGISTERED; + } + + DirectoryResult result; + + if (FeatureFlags.cds()) { + result = ContactDiscoveryV2.getDirectoryResult(context, recipient.getE164().get()); + } else { + result = ContactDiscoveryV1.getDirectoryResult(recipient.getE164().get()); + } + + if (result.getNumberRewrites().size() > 0) { + Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers."); + recipientDatabase.updatePhoneNumbers(result.getNumberRewrites()); + } + + if (result.getRegisteredNumbers().size() > 0) { + recipientDatabase.markRegistered(recipient.getId(), result.getRegisteredNumbers().values().iterator().next()); + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + + if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) { + updateContactsDatabase(context, Collections.singletonList(recipient.getId()), false, result.getNumberRewrites()); + } + + newRegisteredState = result.getRegisteredNumbers().size() > 0 ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED; + if (newRegisteredState != originalRegisteredState) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + + if (notifyOfNewUsers && newRegisteredState == RegisteredState.REGISTERED && recipient.resolve().isSystemContact()) { + notifyNewUsers(context, Collections.singletonList(recipient.getId())); + } + StorageSyncHelper.scheduleSyncForDataChange(); } return newRegisteredState; } + + private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException { + try { + ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE).get(10, TimeUnit.SECONDS); + return true; + } catch (ExecutionException e) { + if (e.getCause() instanceof NotFoundException) { + return false; + } else { + throw new IOException(e); + } + } catch (InterruptedException | TimeoutException e) { + throw new IOException(e); + } + } + + private static void updateContactsDatabase(@NonNull Context context, + @NonNull Collection activeIds, + boolean removeMissing, + @NonNull Map rewrites) + { + AccountHolder account = getOrCreateSystemAccount(context); + + if (account == null) { + Log.w(TAG, "Failed to create an account!"); + return; + } + + try { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ContactsDatabase contactsDatabase = DatabaseFactory.getContactsDatabase(context); + List activeAddresses = Stream.of(activeIds) + .map(Recipient::resolved) + .filter(Recipient::hasE164) + .map(Recipient::requireE164) + .toList(); + + contactsDatabase.removeDeletedRawContacts(account.getAccount()); + contactsDatabase.setRegisteredUsers(account.getAccount(), activeAddresses, removeMissing); + + Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context); + BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate(); + + try { + while (cursor != null && cursor.moveToNext()) { + String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)); + + if (isValidContactNumber(number)) { + String formattedNumber = PhoneNumberFormatter.get(context).format(number); + String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber); + RecipientId recipientId = Recipient.externalContact(context, realNumber).getId(); + String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); + String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI)); + String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL)); + int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)); + Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY))); + + handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString()); + } + } + } finally { + handle.finish(); + } + + if (NotificationChannels.supported()) { + try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = recipients.getNext()) != null) { + NotificationChannels.updateContactChannelName(context, recipient); + } + } + } + } catch (RemoteException | OperationApplicationException e) { + Log.w(TAG, "Failed to update contacts.", e); + } + } + + private static boolean isValidContactNumber(@Nullable String number) { + return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number); + } + + private static @Nullable AccountHolder getOrCreateSystemAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account[] accounts = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID); + + AccountHolder account; + + if (accounts.length == 0) { + account = createAccount(context); + } else { + account = new AccountHolder(accounts[0], false); + } + + if (account != null && !ContentResolver.getSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY)) { + ContentResolver.setSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY, true); + } + + return account; + } + + private static @Nullable AccountHolder createAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account account = new Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID); + + if (accountManager.addAccountExplicitly(account, null, null)) { + Log.i(TAG, "Created new account..."); + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); + return new AccountHolder(account, true); + } else { + Log.w(TAG, "Failed to create account!"); + return null; + } + } + + private static void notifyNewUsers(@NonNull Context context, + @NonNull Collection newUsers) + { + if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return; + + for (RecipientId newUser: newUsers) { + Recipient recipient = Recipient.resolved(newUser); + if (!SessionUtil.hasSession(context, recipient.getId()) && !recipient.isLocalNumber()) { + IncomingJoinedMessage message = new IncomingJoinedMessage(newUser); + Optional insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message); + + if (insertResult.isPresent()) { + int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); + if (hour >= 9 && hour < 23) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true); + } else { + Log.i(TAG, "Not notifying of a new user due to the time of day. (Hour: " + hour + ")"); + } + } + } + } + } + + private static Set sanitizeNumbers(@NonNull Set numbers) { + return Stream.of(numbers).filter(number -> { + try { + return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0; + } catch (NumberFormatException e) { + return false; + } + }).collect(Collectors.toSet()); + } + + static class DirectoryResult { + private final Map registeredNumbers; + private final Map numberRewrites; + + DirectoryResult(@NonNull Map registeredNumbers, + @NonNull Map numberRewrites) + { + this.registeredNumbers = registeredNumbers; + this.numberRewrites = numberRewrites; + } + + + @NonNull Map getRegisteredNumbers() { + return registeredNumbers; + } + + @NonNull Map getNumberRewrites() { + return numberRewrites; + } + } + + private static class AccountHolder { + private final boolean fresh; + private final Account account; + + private AccountHolder(Account account, boolean fresh) { + this.fresh = fresh; + this.account = account; + } + + @SuppressWarnings("unused") + public boolean isFresh() { + return fresh; + } + + public Account getAccount() { + return account; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java deleted file mode 100644 index bd67694a1c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java +++ /dev/null @@ -1,401 +0,0 @@ -package org.thoughtcrime.securesms.contacts.sync; - -import android.Manifest; -import android.accounts.Account; -import android.accounts.AccountManager; -import android.annotation.SuppressLint; -import android.content.ContentResolver; -import android.content.Context; -import android.content.OperationApplicationException; -import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; -import android.provider.ContactsContract; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.contacts.ContactAccessor; -import org.thoughtcrime.securesms.crypto.SessionUtil; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.sms.IncomingJoinedMessage; -import org.thoughtcrime.securesms.util.ProfileUtil; -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.profiles.SignalServiceProfile; -import org.whispersystems.signalservice.api.push.ContactTokenDetails; -import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; -import org.whispersystems.signalservice.api.util.UuidUtil; - -import java.io.IOException; -import java.util.Calendar; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -class DirectoryHelperV1 { - - private static final String TAG = DirectoryHelperV1.class.getSimpleName(); - - @WorkerThread - static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException { - if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) return; - if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) return; - - List newlyActiveUsers = refreshDirectory(context, ApplicationDependencies.getSignalServiceAccountManager()); - - if (TextSecurePreferences.isMultiDevice(context)) { - ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); - } - - if (notifyOfNewUsers) notifyNewUsers(context, newlyActiveUsers); - } - - @SuppressLint("CheckResult") - private static @NonNull List refreshDirectory(@NonNull Context context, @NonNull SignalServiceAccountManager accountManager) throws IOException { - if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) { - return Collections.emptyList(); - } - - if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) { - return Collections.emptyList(); - } - - RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); - Set allRecipientNumbers = recipientDatabase.getAllPhoneNumbers(); - Stream eligibleRecipientDatabaseContactNumbers = Stream.of(allRecipientNumbers); - Stream eligibleSystemDatabaseContactNumbers = Stream.of(ContactAccessor.getInstance().getAllContactsWithNumbers(context)); - Set eligibleContactNumbers = Stream.concat(eligibleRecipientDatabaseContactNumbers, eligibleSystemDatabaseContactNumbers).collect(Collectors.toSet()); - Set storedNumbers = Stream.of(allRecipientNumbers).collect(Collectors.toSet()); - DirectoryResult directoryResult = getDirectoryResult(context, accountManager, recipientDatabase, storedNumbers, eligibleContactNumbers); - - return directoryResult.getNewlyActiveRecipients(); - } - - @WorkerThread - static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException { - RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); - - if (recipient.getUuid().isPresent() && !recipient.getE164().isPresent()) { - boolean isRegistered = isUuidRegistered(context, recipient); - if (isRegistered) { - recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get()); - } else { - recipientDatabase.markUnregistered(recipient.getId()); - } - - return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED; - } - - return getRegisteredState(context, ApplicationDependencies.getSignalServiceAccountManager(), recipientDatabase, recipient); - } - - private static void updateContactsDatabase(@NonNull Context context, @NonNull List activeIds, boolean removeMissing, Map rewrites) { - Optional account = getOrCreateAccount(context); - - if (account.isPresent()) { - try { - List activeAddresses = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasE164).map(Recipient::requireE164).toList(); - - DatabaseFactory.getContactsDatabase(context).removeDeletedRawContacts(account.get().getAccount()); - DatabaseFactory.getContactsDatabase(context).setRegisteredUsers(account.get().getAccount(), activeAddresses, removeMissing); - - Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context); - RecipientDatabase.BulkOperationsHandle handle = DatabaseFactory.getRecipientDatabase(context).beginBulkSystemContactUpdate(); - - try { - while (cursor != null && cursor.moveToNext()) { - String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)); - - if (isValidContactNumber(number)) { - String formattedNumber = PhoneNumberFormatter.get(context).format(number); - String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber); - RecipientId recipientId = Recipient.externalContact(context, realNumber).getId(); - String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); - String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI)); - String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL)); - int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)); - Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)), - cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY))); - - handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString()); - } - } - } finally { - handle.finish(); - } - - if (NotificationChannels.supported()) { - try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) { - Recipient recipient; - while ((recipient = recipients.getNext()) != null) { - NotificationChannels.updateContactChannelName(context, recipient); - } - } - } - } catch (RemoteException | OperationApplicationException e) { - Log.w(TAG, "Failed to update contacts.", e); - } - } - } - - private static void notifyNewUsers(@NonNull Context context, - @NonNull List newUsers) - { - if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return; - - for (RecipientId newUser: newUsers) { - Recipient recipient = Recipient.resolved(newUser); - if (!SessionUtil.hasSession(context, recipient.getId()) && !recipient.isLocalNumber()) { - IncomingJoinedMessage message = new IncomingJoinedMessage(newUser); - Optional insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message); - - if (insertResult.isPresent()) { - int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); - if (hour >= 9 && hour < 23) { - ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true); - } else { - ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), false); - } - } - } - } - } - - private static Optional getOrCreateAccount(Context context) { - AccountManager accountManager = AccountManager.get(context); - Account[] accounts = accountManager.getAccountsByType("org.thoughtcrime.securesms"); - - Optional account; - - if (accounts.length == 0) account = createAccount(context); - else account = Optional.of(new AccountHolder(accounts[0], false)); - - if (account.isPresent() && !ContentResolver.getSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY)) { - ContentResolver.setSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY, true); - } - - return account; - } - - private static Optional createAccount(Context context) { - AccountManager accountManager = AccountManager.get(context); - Account account = new Account(context.getString(R.string.app_name), "org.thoughtcrime.securesms"); - - if (accountManager.addAccountExplicitly(account, null, null)) { - Log.i(TAG, "Created new account..."); - ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); - return Optional.of(new AccountHolder(account, true)); - } else { - Log.w(TAG, "Failed to create account!"); - return Optional.absent(); - } - } - - private static DirectoryResult getDirectoryResult(@NonNull Context context, - @NonNull SignalServiceAccountManager accountManager, - @NonNull RecipientDatabase recipientDatabase, - @NonNull Set locallyStoredNumbers, - @NonNull Set eligibleContactNumbers) - throws IOException - { - FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(eligibleContactNumbers, locallyStoredNumbers); - List activeTokens = accountManager.getContacts(inputResult.getNumbers()); - Set activeNumbers = Stream.of(activeTokens).map(ContactTokenDetails::getNumber).collect(Collectors.toSet()); - FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(activeNumbers, inputResult); - - if (inputResult.getFuzzies().size() > 0) { - Log.i(TAG, "[getDirectoryResult] Got a fuzzy number result."); - } - - if (outputResult.getRewrites().size() > 0) { - Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers."); - } - - recipientDatabase.updatePhoneNumbers(outputResult.getRewrites()); - - List activeIds = new LinkedList<>(); - List inactiveIds = new LinkedList<>(); - - Set inactiveContactNumbers = new HashSet<>(inputResult.getNumbers()); - inactiveContactNumbers.removeAll(outputResult.getRewrites().keySet()); - - for (String number : outputResult.getNumbers()) { - activeIds.add(recipientDatabase.getOrInsertFromE164(number)); - inactiveContactNumbers.remove(number); - } - - for (String inactiveContactNumber : inactiveContactNumbers) { - inactiveIds.add(recipientDatabase.getOrInsertFromE164(inactiveContactNumber)); - } - - Set currentActiveIds = new HashSet<>(recipientDatabase.getRegistered()); - Set contactIds = new HashSet<>(recipientDatabase.getSystemContacts()); - List newlyActiveIds = Stream.of(activeIds) - .filter(id -> !currentActiveIds.contains(id)) - .filter(contactIds::contains) - .toList(); - - recipientDatabase.setRegistered(activeIds, inactiveIds); - updateContactsDatabase(context, activeIds, true, outputResult.getRewrites()); - - Set activeContactNumbers = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasSmsAddress).map(Recipient::requireSmsAddress).collect(Collectors.toSet()); - - if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context)) { - return new DirectoryResult(activeContactNumbers, newlyActiveIds); - } else { - TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true); - return new DirectoryResult(activeContactNumbers); - } - } - - private static RegisteredState getRegisteredState(@NonNull Context context, - @NonNull SignalServiceAccountManager accountManager, - @NonNull RecipientDatabase recipientDatabase, - @NonNull Recipient recipient) - throws IOException - { - boolean activeUser = recipient.resolve().getRegistered() == RegisteredState.REGISTERED; - boolean systemContact = recipient.isSystemContact(); - Optional details = Optional.absent(); - Map rewrites = new HashMap<>(); - - if (recipient.hasE164()) { - FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(Collections.singletonList(recipient.requireE164()), recipientDatabase.getAllPhoneNumbers()); - - if (inputResult.getNumbers().size() > 1) { - Log.i(TAG, "[getRegisteredState] Got a fuzzy number result."); - - List detailList = accountManager.getContacts(inputResult.getNumbers()); - Collection registered = Stream.of(detailList).map(ContactTokenDetails::getNumber).collect(Collectors.toSet()); - FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(registered, inputResult); - String finalNumber = recipient.requireE164(); - ContactTokenDetails detail = new ContactTokenDetails(); - - if (outputResult.getRewrites().size() > 0 && outputResult.getRewrites().containsKey(finalNumber)) { - Log.i(TAG, "[getRegisteredState] Need to rewrite a number."); - finalNumber = outputResult.getRewrites().get(finalNumber); - rewrites = outputResult.getRewrites(); - } - - detail.setNumber(finalNumber); - details = Optional.of(detail); - - recipientDatabase.updatePhoneNumbers(outputResult.getRewrites()); - } else { - details = accountManager.getContact(recipient.requireE164()); - } - } - - if (details.isPresent()) { - recipientDatabase.setRegistered(recipient.getId(), RegisteredState.REGISTERED); - - if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) { - updateContactsDatabase(context, Util.asList(recipient.getId()), false, rewrites); - } - - if (!activeUser && TextSecurePreferences.isMultiDevice(context)) { - ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); - } - - if (!activeUser && systemContact && !TextSecurePreferences.getNeedsSqlCipherMigration(context)) { - notifyNewUsers(context, Collections.singletonList(recipient.getId())); - } - - return RegisteredState.REGISTERED; - } else { - recipientDatabase.setRegistered(recipient.getId(), RegisteredState.NOT_REGISTERED); - return RegisteredState.NOT_REGISTERED; - } - } - - private static boolean isValidContactNumber(@Nullable String number) { - return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number); - } - - private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException { - try { - ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE).get(10, TimeUnit.SECONDS); - return true; - } catch (ExecutionException e) { - if (e.getCause() instanceof NotFoundException) { - return false; - } else { - throw new IOException(e); - } - } catch (InterruptedException | TimeoutException e) { - throw new IOException(e); - } - } - - private static class DirectoryResult { - - private final Set numbers; - private final List newlyActiveRecipients; - - DirectoryResult(@NonNull Set numbers) { - this(numbers, Collections.emptyList()); - } - - DirectoryResult(@NonNull Set numbers, @NonNull List newlyActiveRecipients) { - this.numbers = numbers; - this.newlyActiveRecipients = newlyActiveRecipients; - } - - Set getNumbers() { - return numbers; - } - - List getNewlyActiveRecipients() { - return newlyActiveRecipients; - } - } - - private static class AccountHolder { - - private final boolean fresh; - private final Account account; - - private AccountHolder(Account account, boolean fresh) { - this.fresh = fresh; - this.account = account; - } - - @SuppressWarnings("unused") - public boolean isFresh() { - return fresh; - } - - public Account getAccount() { - return account; - } - - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java index 2618e42d74..cad867f932 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.UUID; /** * A helper class to match a single number with multiple possible registered numbers. An example is @@ -67,6 +68,32 @@ class FuzzyPhoneNumberHelper { return new OutputResult(allNumbers, rewrites); } + /** + * This should be run on the list of numbers we find out are registered with the server. Based on + * these results and our initial input set, we can decide if we need to rewrite which number we + * have stored locally. + */ + static @NonNull OutputResultV2 generateOutputV2(@NonNull Map registeredNumbers, @NonNull InputResult inputResult) { + Map allNumbers = new HashMap<>(registeredNumbers); + Map rewrites = new HashMap<>(); + + for (Map.Entry entry : inputResult.getFuzzies().entrySet()) { + if (registeredNumbers.containsKey(entry.getKey()) && registeredNumbers.containsKey(entry.getValue())) { + if (mxHas1(entry.getKey())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } else { + allNumbers.remove(entry.getValue()); + } + } else if (registeredNumbers.containsKey(entry.getValue())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } + } + + return new OutputResultV2(allNumbers, rewrites); + } + private static boolean mx(@NonNull String number) { return number.startsWith("+52") && (number.length() == 13 || number.length() == 14); @@ -127,4 +154,22 @@ class FuzzyPhoneNumberHelper { return rewrites; } } + + public static class OutputResultV2 { + private final Map numbers; + private final Map rewrites; + + private OutputResultV2(@NonNull Map numbers, @NonNull Map rewrites) { + this.numbers = numbers; + this.rewrites = rewrites; + } + + public @NonNull Map getNumbers() { + return numbers; + } + + public @NonNull Map getRewrites() { + return rewrites; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 1c6363933a..0fccac6526 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1321,10 +1321,14 @@ public class RecipientDatabase extends Database { return results; } - public void markRegistered(@NonNull RecipientId id, @NonNull UUID uuid) { - ContentValues contentValues = new ContentValues(3); + public void markRegistered(@NonNull RecipientId id, @Nullable UUID uuid) { + ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); - contentValues.put(UUID, uuid.toString().toLowerCase()); + + if (uuid != null) { + contentValues.put(UUID, uuid.toString().toLowerCase()); + } + if (update(id, contentValues)) { markDirty(id, DirtyState.INSERT); Recipient.live(id).refresh(); @@ -1337,7 +1341,7 @@ public class RecipientDatabase extends Database { * preferred. */ public void markRegistered(@NonNull RecipientId id) { - ContentValues contentValues = new ContentValues(2); + ContentValues contentValues = new ContentValues(1); contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); if (update(id, contentValues)) { markDirty(id, DirtyState.INSERT); @@ -1348,7 +1352,7 @@ public class RecipientDatabase extends Database { public void markUnregistered(@NonNull RecipientId id) { ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); - contentValues.put(UUID, (String) null); + contentValues.putNull(UUID); if (update(id, contentValues)) { markDirty(id, DirtyState.DELETE); Recipient.live(id).refresh(); @@ -1363,14 +1367,18 @@ public class RecipientDatabase extends Database { for (Map.Entry entry : registered.entrySet()) { ContentValues values = new ContentValues(2); values.put(REGISTERED, RegisteredState.REGISTERED.getId()); - values.put(UUID, entry.getValue().toLowerCase()); + + if (entry.getValue() != null) { + values.put(UUID, entry.getValue().toLowerCase()); + } + if (update(entry.getKey(), values)) { markDirty(entry.getKey(), DirtyState.INSERT); } } for (RecipientId id : unregistered) { - ContentValues values = new ContentValues(1); + ContentValues values = new ContentValues(2); values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); values.put(UUID, (String) null); if (update(id, values)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index b5d0d1da2a..0a28969d05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -63,6 +63,7 @@ public final class FeatureFlags { private static final String GROUPS_V2_CREATE = "android.groupsv2.create"; private static final String GROUPS_V2_CAPACITY = "android.groupsv2.capacity"; private static final String GROUPS_V2_INTERNAL_TEST = "android.groupsv2.internalTest"; + private static final String CDS = "android.cds"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -256,6 +257,11 @@ public final class FeatureFlags { return groupsV2() && getBoolean(GROUPS_V2_INTERNAL_TEST, false); } + /** Whether or not to use the new contact discovery service endpoint. */ + public static boolean cds() { + return getBoolean(CDS, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java index cf53d18123..34de3fe3c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java @@ -19,13 +19,9 @@ public final class SetUtil { return difference; } - public static Set union(Set... sets) { - Set result = new LinkedHashSet<>(); - - for (Set set : sets) { - result.addAll(set); - } - + public static Set union(Set a, Set b) { + Set result = new LinkedHashSet<>(a); + result.addAll(b); return result; } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java index a8c6cb8fb6..b527b3d35e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java @@ -3,15 +3,21 @@ package org.thoughtcrime.securesms.contacts.sync; import org.junit.Test; import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.InputResult; import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.OutputResult; +import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.OutputResultV2; +import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.UUID; import edu.emory.mathcs.backport.java.util.Arrays; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.thoughtcrime.securesms.testutil.TestHelpers.mapOf; public class FuzzyPhoneNumberHelperTest { @@ -21,6 +27,9 @@ public class FuzzyPhoneNumberHelperTest { private static final String MX_A = "+525512345678"; private static final String MX_A_1 = "+5215512345678"; + private static final UUID UUID_A = UuidUtil.parseOrThrow("db980097-1e02-452f-9937-899630508705"); + private static final UUID UUID_B = UuidUtil.parseOrThrow("11ccd6de-8fcc-49d6-bb9e-df21ff88bd6f"); + @Test public void generateInput_noMxNumbers() { InputResult result = FuzzyPhoneNumberHelper.generateInput(setOf(US_A, US_B), setOf(US_A, US_B)); @@ -156,6 +165,69 @@ public class FuzzyPhoneNumberHelperTest { assertEquals(MX_A_1, result.getRewrites().get(MX_A)); } + @Test + public void generateOutputV2_noMxNumbers() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(US_A, UUID_A, US_B, UUID_B), new InputResult(setOf(US_A, US_B), Collections.emptyMap())); + + assertEquals(2, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(US_A)); + assertEquals(UUID_B, result.getNumbers().get(US_B)); + assertTrue(result.getRewrites().isEmpty()); + } + + @Test + public void generateOutputV2_bothMatch_no1To1() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(MX_A, UUID_A, MX_A_1, UUID_B), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A, MX_A_1))); + + assertEquals(1, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(MX_A)); + assertTrue(result.getRewrites().isEmpty()); + } + + @Test + public void generateOutputV2_bothMatch_1toNo1() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(MX_A, UUID_A, MX_A_1, UUID_B), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A_1, MX_A))); + + assertEquals(1, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(MX_A)); + assertEquals(MX_A, result.getRewrites().get(MX_A_1)); + } + + @Test + public void generateOutputV2_no1Match_no1To1() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(MX_A, UUID_A), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A, MX_A_1))); + + assertEquals(1, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(MX_A)); + assertTrue(result.getRewrites().isEmpty()); + } + + @Test + public void generateOutputV2_no1Match_1ToNo1() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(MX_A, UUID_A), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A_1, MX_A))); + + assertEquals(1, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(MX_A)); + assertEquals(MX_A, result.getRewrites().get(MX_A_1)); + } + + @Test + public void generateOutputV2_1Match_1ToNo1() { + OutputResultV2 result = FuzzyPhoneNumberHelper.generateOutputV2(mapOf(MX_A_1, UUID_A), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A_1, MX_A))); + + assertEquals(1, result.getNumbers().size()); + assertEquals(UUID_A, result.getNumbers().get(MX_A_1)); + assertTrue(result.getRewrites().isEmpty()); + } + + @Test + public void generateOutputV2_1Match_no1To1() { + OutputResult result = FuzzyPhoneNumberHelper.generateOutput(setOf(MX_A_1), new InputResult(setOf(MX_A, MX_A_1), Collections.singletonMap(MX_A, MX_A_1))); + + assertEquals(1, result.getNumbers().size()); + assertTrue(result.getNumbers().containsAll(setOf(MX_A_1))); + assertEquals(MX_A_1, result.getRewrites().get(MX_A)); + } private static Set setOf(E... values) { //noinspection unchecked diff --git a/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java b/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java index 5f318bbdf6..13909173f5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java +++ b/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java @@ -9,7 +9,9 @@ import org.whispersystems.libsignal.util.ByteUtil; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import static junit.framework.TestCase.assertTrue; @@ -58,4 +60,21 @@ public final class TestHelpers { assertEquals(a.size(), b.size()); assertTrue(a.containsAll(b)); } + + public static Map mapOf() { + return new HashMap<>(); + } + + public static Map mapOf(K k, V v) { + return new HashMap() {{ + put(k, v); + }}; + } + + public static Map mapOf(K k1, V v1, K k2, V v2) { + return new HashMap() {{ + put(k1, v1); + put(k2, v2); + }}; + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/FeatureFlagsTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/FeatureFlagsTest.java index 10945d3953..00ccf2c0ac 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/FeatureFlagsTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/FeatureFlagsTest.java @@ -14,6 +14,7 @@ import java.util.Set; import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.thoughtcrime.securesms.testutil.TestHelpers.mapOf; public class FeatureFlagsTest extends BaseUnitTest { @@ -367,21 +368,4 @@ public class FeatureFlagsTest extends BaseUnitTest { private static Set setOf(V... values) { return new HashSet<>(Arrays.asList(values)); } - - private static Map mapOf() { - return new HashMap<>(); - } - - private static Map mapOf(K k, V v) { - return new HashMap() {{ - put(k, v); - }}; - } - - private static Map mapOf(K k1, V v1, K k2, V v2) { - return new HashMap() {{ - put(k1, v1); - put(k2, v2); - }}; - } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 067e02bf24..0111e53f28 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -73,6 +73,8 @@ import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider; import org.whispersystems.signalservice.internal.util.Util; import org.whispersystems.util.Base64; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.KeyStore; @@ -363,29 +365,38 @@ public class SignalServiceAccountManager { return activeTokens; } - public List getRegisteredUsers(KeyStore iasKeyStore, Set e164numbers, String enclaveId) + public Map getRegisteredUsers(KeyStore iasKeyStore, Set e164numbers, String mrenclave) throws IOException, Quote.InvalidQuoteFormatException, UnauthenticatedQuoteException, SignatureException, UnauthenticatedResponseException { try { - String authorization = pushServiceSocket.getContactDiscoveryAuthorization(); - RemoteAttestation remoteAttestation = RemoteAttestationUtil.getAndVerifyRemoteAttestation(pushServiceSocket, PushServiceSocket.ClientSet.ContactDiscovery, iasKeyStore, enclaveId, enclaveId, authorization); - List addressBook = new LinkedList<>(); + String authorization = this.pushServiceSocket.getContactDiscoveryAuthorization(); + Map attestations = RemoteAttestationUtil.getAndVerifyMultiRemoteAttestation(pushServiceSocket, + PushServiceSocket.ClientSet.ContactDiscovery, + iasKeyStore, + mrenclave, + mrenclave, + authorization); + + List addressBook = new ArrayList<>(e164numbers.size()); for (String e164number : e164numbers) { addressBook.add(e164number.substring(1)); } - DiscoveryRequest request = ContactDiscoveryCipher.createDiscoveryRequest(addressBook, remoteAttestation); - DiscoveryResponse response = pushServiceSocket.getContactDiscoveryRegisteredUsers(authorization, request, remoteAttestation.getCookies(), enclaveId); - byte[] data = ContactDiscoveryCipher.getDiscoveryResponseData(response, remoteAttestation); + List cookies = attestations.values().iterator().next().getCookies(); + DiscoveryRequest request = ContactDiscoveryCipher.createDiscoveryRequest(addressBook, attestations); + DiscoveryResponse response = this.pushServiceSocket.getContactDiscoveryRegisteredUsers(authorization, request, cookies, mrenclave); + byte[] data = ContactDiscoveryCipher.getDiscoveryResponseData(response, attestations.values()); - Iterator addressBookIterator = addressBook.iterator(); - List results = new LinkedList<>(); + HashMap results = new HashMap<>(addressBook.size()); + DataInputStream uuidInputStream = new DataInputStream(new ByteArrayInputStream(data)); - for (byte aData : data) { - String candidate = addressBookIterator.next(); - - if (aData != 0) results.add('+' + candidate); + for (String candidate : addressBook) { + long candidateUuidHigh = uuidInputStream.readLong(); + long candidateUuidLow = uuidInputStream.readLong(); + if (candidateUuidHigh != 0 || candidateUuidLow != 0) { + results.put('+' + candidate, new UUID(candidateUuidHigh, candidateUuidLow)); + } } return results; @@ -394,38 +405,6 @@ public class SignalServiceAccountManager { } } - public void reportContactDiscoveryServiceMatch() { - try { - this.pushServiceSocket.reportContactDiscoveryServiceMatch(); - } catch (IOException e) { - Log.w(TAG, "Request to indicate a contact discovery result match failed. Ignoring.", e); - } - } - - public void reportContactDiscoveryServiceMismatch() { - try { - this.pushServiceSocket.reportContactDiscoveryServiceMismatch(); - } catch (IOException e) { - Log.w(TAG, "Request to indicate a contact discovery result mismatch failed. Ignoring.", e); - } - } - - public void reportContactDiscoveryServiceAttestationError(String reason) { - try { - this.pushServiceSocket.reportContactDiscoveryServiceAttestationError(reason); - } catch (IOException e) { - Log.w(TAG, "Request to indicate a contact discovery attestation error failed. Ignoring.", e); - } - } - - public void reportContactDiscoveryServiceUnexpectedError(String reason) { - try { - this.pushServiceSocket.reportContactDiscoveryServiceUnexpectedError(reason); - } catch (IOException e) { - Log.w(TAG, "Request to indicate a contact discovery unexpected error failed. Ignoring.", e); - } - } - public Optional getStorageManifest(StorageKey storageKey) throws IOException { try { String authToken = this.pushServiceSocket.getStorageAuth(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/CryptoUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/CryptoUtil.java index 8862f43d35..b0f023cfe5 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/CryptoUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/CryptoUtil.java @@ -1,6 +1,7 @@ package org.whispersystems.signalservice.api.crypto; import java.security.InvalidKeyException; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import javax.crypto.Mac; @@ -22,4 +23,13 @@ public final class CryptoUtil { throw new AssertionError(e); } } + + public static byte[] sha256(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(data); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/AESCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/AESCipher.java index 19b7fdc4a6..19090eb7d7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/AESCipher.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/AESCipher.java @@ -39,7 +39,9 @@ final class AESCipher { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_LENGTH_BITS, iv)); - cipher.updateAAD(aad); + if (aad != null) { + cipher.updateAAD(aad); + } byte[] cipherText = cipher.doFinal(requestData); byte[][] parts = ByteUtil.split(cipherText, cipherText.length - TAG_LENGTH_BYTES, TAG_LENGTH_BYTES); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java index d2cf0d2087..25cd414749 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/crypto/ContactDiscoveryCipher.java @@ -1,40 +1,81 @@ package org.whispersystems.signalservice.internal.contacts.crypto; import org.whispersystems.libsignal.util.ByteUtil; +import org.whispersystems.signalservice.api.crypto.CryptoUtil; import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.internal.contacts.crypto.AESCipher.AESEncryptedResult; import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest; import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse; +import org.whispersystems.signalservice.internal.contacts.entities.QueryEnvelope; +import org.whispersystems.signalservice.internal.util.Util; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; public final class ContactDiscoveryCipher { private ContactDiscoveryCipher() { } - public static DiscoveryRequest createDiscoveryRequest(List addressBook, RemoteAttestation remoteAttestation) { + public static DiscoveryRequest createDiscoveryRequest(List addressBook, Map remoteAttestations) { + byte[] queryDataKey = Util.getSecretBytes(32); + byte[] queryData = buildQueryData(addressBook); + AESEncryptedResult encryptedQueryData = AESCipher.encrypt(queryDataKey, null, queryData); + byte[] commitment = CryptoUtil.sha256(queryData); + Map envelopes = new HashMap<>(remoteAttestations.size()); + + for (Map.Entry entry : remoteAttestations.entrySet()) { + envelopes.put(entry.getKey(), + buildQueryEnvelope(entry.getValue().getRequestId(), + entry.getValue().getKeys().getClientKey(), + queryDataKey)); + } + + return new DiscoveryRequest(addressBook.size(), + commitment, + encryptedQueryData.iv, + encryptedQueryData.data, + encryptedQueryData.mac, + envelopes); + } + + public static byte[] getDiscoveryResponseData(DiscoveryResponse response, Collection attestations) throws InvalidCiphertextException, IOException { + for (RemoteAttestation attestation : attestations) { + if (Arrays.equals(response.getRequestId(), attestation.getRequestId())) { + return AESCipher.decrypt(attestation.getKeys().getServerKey(), response.getIv(), response.getData(), response.getMac()); + } + } + throw new NoMatchingRequestIdException(); + } + + private static byte[] buildQueryData(List addresses) { try { + byte[] nonce = Util.getSecretBytes(32); ByteArrayOutputStream requestDataStream = new ByteArrayOutputStream(); - for (String address : addressBook) { + requestDataStream.write(nonce); + + for (String address : addresses) { requestDataStream.write(ByteUtil.longToByteArray(Long.parseLong(address))); } - byte[] clientKey = remoteAttestation.getKeys().getClientKey(); - byte[] requestData = requestDataStream.toByteArray(); - byte[] aad = remoteAttestation.getRequestId(); - - AESCipher.AESEncryptedResult aesEncryptedResult = AESCipher.encrypt(clientKey, aad, requestData); - - return new DiscoveryRequest(addressBook.size(), aesEncryptedResult.aad, aesEncryptedResult.iv, aesEncryptedResult.data, aesEncryptedResult.mac); + return requestDataStream.toByteArray(); } catch (IOException e) { throw new AssertionError(e); } } - public static byte[] getDiscoveryResponseData(DiscoveryResponse response, RemoteAttestation remoteAttestation) throws InvalidCiphertextException { - return AESCipher.decrypt(remoteAttestation.getKeys().getServerKey(), response.getIv(), response.getData(), response.getMac()); + private static QueryEnvelope buildQueryEnvelope(byte[] requestId, byte[] clientKey, byte[] queryDataKey) { + AESEncryptedResult result = AESCipher.encrypt(clientKey, requestId, queryDataKey); + return new QueryEnvelope(requestId, result.iv, result.data, result.mac); + } + + static class NoMatchingRequestIdException extends IOException { } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java index 3ed0259ed9..3acf9ef412 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryRequest.java @@ -18,8 +18,8 @@ package org.whispersystems.signalservice.internal.contacts.entities; import com.fasterxml.jackson.annotation.JsonProperty; -import org.whispersystems.signalservice.internal.util.Hex; - +import java.util.List; +import java.util.Map; public class DiscoveryRequest { @@ -27,7 +27,7 @@ public class DiscoveryRequest { private int addressCount; @JsonProperty - private byte[] requestId; + private byte[] commitment; @JsonProperty private byte[] iv; @@ -38,20 +38,22 @@ public class DiscoveryRequest { @JsonProperty private byte[] mac; - public DiscoveryRequest() { + @JsonProperty + private Map envelopes; - } + public DiscoveryRequest() { } - public DiscoveryRequest(int addressCount, byte[] requestId, byte[] iv, byte[] data, byte[] mac) { + public DiscoveryRequest(int addressCount, byte[] commitment, byte[] iv, byte[] data, byte[] mac, Map envelopes) { this.addressCount = addressCount; - this.requestId = requestId; + this.commitment = commitment; this.iv = iv; this.data = data; this.mac = mac; + this.envelopes = envelopes; } - public byte[] getRequestId() { - return requestId; + public byte[] getCommitment() { + return commitment; } public byte[] getIv() { @@ -70,8 +72,8 @@ public class DiscoveryRequest { return addressCount; } + @Override public String toString() { - return "{ addressCount: " + addressCount + ", ticket: " + Hex.toString(requestId) + ", iv: " + Hex.toString(iv) + ", data: " + Hex.toString(data) + ", mac: " + Hex.toString(mac) + "}"; + return "{ addressCount: " + addressCount + ", envelopes: " + envelopes.size() + " }"; } - } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java index e3ff43b2e4..56de4ba092 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/DiscoveryResponse.java @@ -22,6 +22,9 @@ import org.whispersystems.signalservice.internal.util.Hex; public class DiscoveryResponse { + @JsonProperty + private byte[] requestId; + @JsonProperty private byte[] iv; @@ -33,10 +36,8 @@ public class DiscoveryResponse { public DiscoveryResponse() {} - public DiscoveryResponse(byte[] iv, byte[] data, byte[] mac) { - this.iv = iv; - this.data = data; - this.mac = mac; + public byte[] getRequestId() { + return requestId; } public byte[] getIv() { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/MultiRemoteAttestationResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/MultiRemoteAttestationResponse.java new file mode 100644 index 0000000000..9aa6423fcb --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/MultiRemoteAttestationResponse.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +public class MultiRemoteAttestationResponse { + + @JsonProperty + private Map attestations; + + public Map getAttestations() { + return attestations; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/QueryEnvelope.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/QueryEnvelope.java new file mode 100644 index 0000000000..9244a6b409 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/contacts/entities/QueryEnvelope.java @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.internal.contacts.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class QueryEnvelope { + + @JsonProperty + private byte[] requestId; + + @JsonProperty + private byte[] iv; + + @JsonProperty + private byte[] data; + + @JsonProperty + private byte[] mac; + + public QueryEnvelope() { } + + public QueryEnvelope(byte[] requestId, byte[] iv, byte[] data, byte[] mac) { + this.requestId = requestId; + this.iv = iv; + this.data = data; + this.mac = mac; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index e1c9352747..03424cabe2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -852,24 +852,6 @@ public class PushServiceSocket { } } - public void reportContactDiscoveryServiceMatch() throws IOException { - makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "ok"), "PUT", ""); - } - - public void reportContactDiscoveryServiceMismatch() throws IOException { - makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "mismatch"), "PUT", ""); - } - - public void reportContactDiscoveryServiceAttestationError(String reason) throws IOException { - ContactDiscoveryFailureReason failureReason = new ContactDiscoveryFailureReason(reason); - makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "attestation-error"), "PUT", JsonUtil.toJson(failureReason)); - } - - public void reportContactDiscoveryServiceUnexpectedError(String reason) throws IOException { - ContactDiscoveryFailureReason failureReason = new ContactDiscoveryFailureReason(reason); - makeServiceRequest(String.format(DIRECTORY_FEEDBACK_PATH, "unexpected-error"), "PUT", JsonUtil.toJson(failureReason)); - } - public TurnServerInfo getTurnServerInfo() throws IOException { String response = makeServiceRequest(TURN_SERVER_INFO, "GET", null); return JsonUtil.fromJson(response, TurnServerInfo.class); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteAttestationUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteAttestationUtil.java index 4cc4cd30f4..0e6e61624e 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteAttestationUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteAttestationUtil.java @@ -10,6 +10,7 @@ import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestati import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestationCipher; import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestationKeys; import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; +import org.whispersystems.signalservice.internal.contacts.entities.MultiRemoteAttestationResponse; import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationRequest; import org.whispersystems.signalservice.internal.contacts.entities.RemoteAttestationResponse; import org.whispersystems.signalservice.internal.util.JsonUtil; @@ -17,8 +18,11 @@ import org.whispersystems.signalservice.internal.util.JsonUtil; import java.io.IOException; import java.security.KeyStore; import java.security.SignatureException; +import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import okhttp3.Response; import okhttp3.ResponseBody; @@ -36,33 +40,66 @@ public final class RemoteAttestationUtil { String authorization) throws IOException, Quote.InvalidQuoteFormatException, InvalidCiphertextException, UnauthenticatedQuoteException, SignatureException { - Curve25519 curve = Curve25519.getInstance(Curve25519.BEST); - Curve25519KeyPair keyPair = curve.generateKeyPair(); - RemoteAttestationRequest attestationRequest = new RemoteAttestationRequest(keyPair.getPublicKey()); - Pair> attestationResponsePair = getRemoteAttestation(socket, clientSet, authorization, attestationRequest, enclaveName); - RemoteAttestationResponse attestationResponse = attestationResponsePair.first(); - List attestationCookies = attestationResponsePair.second(); + Curve25519KeyPair keyPair = buildKeyPair(); + ResponsePair result = makeAttestationRequest(socket, clientSet, authorization, enclaveName, keyPair); + RemoteAttestationResponse response = JsonUtil.fromJson(result.body, RemoteAttestationResponse.class); - RemoteAttestationKeys keys = new RemoteAttestationKeys(keyPair, attestationResponse.getServerEphemeralPublic(), attestationResponse.getServerStaticPublic()); - Quote quote = new Quote(attestationResponse.getQuote()); - byte[] requestId = RemoteAttestationCipher.getRequestId(keys, attestationResponse); - - RemoteAttestationCipher.verifyServerQuote(quote, attestationResponse.getServerStaticPublic(), mrenclave); - - RemoteAttestationCipher.verifyIasSignature(iasKeyStore, attestationResponse.getCertificates(), attestationResponse.getSignatureBody(), attestationResponse.getSignature(), quote); - - return new RemoteAttestation(requestId, keys, attestationCookies); + return validateAndBuildRemoteAttestation(response, result.cookies, iasKeyStore, keyPair, mrenclave); } - private static Pair> getRemoteAttestation(PushServiceSocket socket, - PushServiceSocket.ClientSet clientSet, - String authorization, - RemoteAttestationRequest request, - String enclaveName) - throws IOException + public static Map getAndVerifyMultiRemoteAttestation(PushServiceSocket socket, + PushServiceSocket.ClientSet clientSet, + KeyStore iasKeyStore, + String enclaveName, + String mrenclave, + String authorization) + throws IOException, Quote.InvalidQuoteFormatException, InvalidCiphertextException, UnauthenticatedQuoteException, SignatureException { - Response response = socket.makeRequest(clientSet, authorization, new LinkedList(), "/v1/attestation/" + enclaveName, "PUT", JsonUtil.toJson(request)); - ResponseBody body = response.body(); + Curve25519KeyPair keyPair = buildKeyPair(); + ResponsePair result = makeAttestationRequest(socket, clientSet, authorization, enclaveName, keyPair); + MultiRemoteAttestationResponse response = JsonUtil.fromJson(result.body, MultiRemoteAttestationResponse.class); + Map attestations = new HashMap<>(); + + if (response.getAttestations().isEmpty() || response.getAttestations().size() > 3) { + throw new NonSuccessfulResponseCodeException("Incorrect number of attestations: " + response.getAttestations().size()); + } + + for (Map.Entry entry : response.getAttestations().entrySet()) { + attestations.put(entry.getKey(), + validateAndBuildRemoteAttestation(entry.getValue(), + result.cookies, + iasKeyStore, + keyPair, + mrenclave)); + } + + return attestations; + } + + private static Curve25519KeyPair buildKeyPair() { + Curve25519 curve = Curve25519.getInstance(Curve25519.BEST); + return curve.generateKeyPair(); + } + + private static ResponsePair makeAttestationRequest(PushServiceSocket socket, + PushServiceSocket.ClientSet clientSet, + String authorization, + String enclaveName, + Curve25519KeyPair keyPair) + throws IOException + { + RemoteAttestationRequest attestationRequest = new RemoteAttestationRequest(keyPair.getPublicKey()); + Response response = socket.makeRequest(clientSet, authorization, new LinkedList(), "/v1/attestation/" + enclaveName, "PUT", JsonUtil.toJson(attestationRequest)); + ResponseBody body = response.body(); + + if (body == null) { + throw new NonSuccessfulResponseCodeException("Empty response!"); + } + + return new ResponsePair(body.string(), parseCookies(response)); + } + + private static List parseCookies(Response response) { List rawCookies = response.headers("Set-Cookie"); List cookies = new LinkedList<>(); @@ -70,10 +107,34 @@ public final class RemoteAttestationUtil { cookies.add(cookie.split(";")[0]); } - if (body != null) { - return new Pair<>(JsonUtil.fromJson(body.string(), RemoteAttestationResponse.class), cookies); - } else { - throw new NonSuccessfulResponseCodeException("Empty response!"); + return cookies; + } + + private static RemoteAttestation validateAndBuildRemoteAttestation(RemoteAttestationResponse response, + List cookies, + KeyStore iasKeyStore, + Curve25519KeyPair keyPair, + String mrenclave) + throws Quote.InvalidQuoteFormatException, InvalidCiphertextException, UnauthenticatedQuoteException, SignatureException + { + RemoteAttestationKeys keys = new RemoteAttestationKeys(keyPair, response.getServerEphemeralPublic(), response.getServerStaticPublic()); + Quote quote = new Quote(response.getQuote()); + byte[] requestId = RemoteAttestationCipher.getRequestId(keys, response); + + RemoteAttestationCipher.verifyServerQuote(quote, response.getServerStaticPublic(), mrenclave); + + RemoteAttestationCipher.verifyIasSignature(iasKeyStore, response.getCertificates(), response.getSignatureBody(), response.getSignature(), quote); + + return new RemoteAttestation(requestId, keys, cookies); + } + + private static class ResponsePair { + final String body; + final List cookies; + + private ResponsePair(String body, List cookies) { + this.body = body; + this.cookies = cookies; } } }