kopia lustrzana https://github.com/ryukoposting/Signal-Android
392 wiersze
20 KiB
Java
392 wiersze
20 KiB
Java
package org.thoughtcrime.securesms.util;
|
|
|
|
import android.content.Context;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.annotation.WorkerThread;
|
|
|
|
import org.signal.core.util.logging.Log;
|
|
import org.signal.libsignal.protocol.IdentityKey;
|
|
import org.signal.libsignal.protocol.IdentityKeyPair;
|
|
import org.signal.libsignal.protocol.InvalidKeyException;
|
|
import org.signal.libsignal.protocol.util.Pair;
|
|
import org.signal.libsignal.zkgroup.InvalidInputException;
|
|
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
|
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
|
import org.thoughtcrime.securesms.badges.models.Badge;
|
|
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
|
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
|
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
|
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob;
|
|
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
|
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
|
|
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
|
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
|
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddressProfileUtil;
|
|
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
|
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
|
import org.thoughtcrime.securesms.profiles.ProfileName;
|
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
|
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
|
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
|
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
|
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
|
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
|
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
|
import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
|
|
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
|
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
|
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
|
import org.whispersystems.signalservice.api.services.ProfileService;
|
|
import org.whispersystems.signalservice.api.util.StreamDetails;
|
|
import org.whispersystems.signalservice.internal.ServiceResponse;
|
|
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
|
|
|
import java.io.IOException;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Optional;
|
|
import java.util.stream.Collectors;
|
|
|
|
import io.reactivex.rxjava3.core.Single;
|
|
|
|
/**
|
|
* Aids in the retrieval and decryption of profiles.
|
|
*/
|
|
public final class ProfileUtil {
|
|
|
|
private static final String TAG = Log.tag(ProfileUtil.class);
|
|
|
|
private ProfileUtil() {
|
|
}
|
|
|
|
/**
|
|
* Should be called after a change to our own profile key as been persisted to the database.
|
|
*/
|
|
@WorkerThread
|
|
public static void handleSelfProfileKeyChange() {
|
|
List<Job> gv2UpdateJobs = SignalDatabase.groups()
|
|
.getAllGroupV2Ids()
|
|
.stream()
|
|
.map(GroupV2UpdateSelfProfileKeyJob::withoutLimits)
|
|
.collect(Collectors.toList());
|
|
|
|
Log.w(TAG, "[handleSelfProfileKeyChange] Scheduling jobs, including " + gv2UpdateJobs.size() + " group update jobs.");
|
|
|
|
ApplicationDependencies.getJobManager()
|
|
.startChain(new RefreshAttributesJob())
|
|
.then(new ProfileUploadJob())
|
|
.then(new MultiDeviceProfileKeyUpdateJob())
|
|
.then(gv2UpdateJobs)
|
|
.enqueue();
|
|
}
|
|
|
|
@WorkerThread
|
|
public static @NonNull ProfileAndCredential retrieveProfileSync(@NonNull Context context,
|
|
@NonNull Recipient recipient,
|
|
@NonNull SignalServiceProfile.RequestType requestType)
|
|
throws IOException
|
|
{
|
|
return retrieveProfileSync(context, recipient, requestType, true);
|
|
}
|
|
|
|
@WorkerThread
|
|
public static @NonNull ProfileAndCredential retrieveProfileSync(@NonNull Context context,
|
|
@NonNull Recipient recipient,
|
|
@NonNull SignalServiceProfile.RequestType requestType,
|
|
boolean allowUnidentifiedAccess)
|
|
throws IOException
|
|
{
|
|
Pair<Recipient, ServiceResponse<ProfileAndCredential>> response = retrieveProfile(context, recipient, requestType, allowUnidentifiedAccess).blockingGet();
|
|
return new ProfileService.ProfileResponseProcessor(response.second()).getResultOrThrow();
|
|
}
|
|
|
|
public static Single<Pair<Recipient, ServiceResponse<ProfileAndCredential>>> retrieveProfile(@NonNull Context context,
|
|
@NonNull Recipient recipient,
|
|
@NonNull SignalServiceProfile.RequestType requestType)
|
|
{
|
|
return retrieveProfile(context, recipient, requestType, true);
|
|
}
|
|
|
|
private static Single<Pair<Recipient, ServiceResponse<ProfileAndCredential>>> retrieveProfile(@NonNull Context context,
|
|
@NonNull Recipient recipient,
|
|
@NonNull SignalServiceProfile.RequestType requestType,
|
|
boolean allowUnidentifiedAccess)
|
|
{
|
|
ProfileService profileService = ApplicationDependencies.getProfileService();
|
|
Optional<UnidentifiedAccess> unidentifiedAccess = allowUnidentifiedAccess ? getUnidentifiedAccess(context, recipient) : Optional.empty();
|
|
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
|
|
|
|
return Single.fromCallable(() -> toSignalServiceAddress(context, recipient))
|
|
.flatMap(address -> profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, Locale.getDefault()).map(p -> new Pair<>(recipient, p)))
|
|
.onErrorReturn(t -> new Pair<>(recipient, ServiceResponse.forUnknownError(t)));
|
|
}
|
|
|
|
public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable byte[] encryptedString)
|
|
throws InvalidCiphertextException, IOException
|
|
{
|
|
if (encryptedString == null) {
|
|
return null;
|
|
}
|
|
|
|
ProfileCipher profileCipher = new ProfileCipher(profileKey);
|
|
return profileCipher.decryptString(encryptedString);
|
|
}
|
|
|
|
public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable String encryptedStringBase64)
|
|
throws InvalidCiphertextException, IOException
|
|
{
|
|
if (encryptedStringBase64 == null) {
|
|
return null;
|
|
}
|
|
|
|
return decryptString(profileKey, Base64.decode(encryptedStringBase64));
|
|
}
|
|
|
|
@WorkerThread
|
|
public static @NonNull MobileCoinPublicAddress getAddressForRecipient(@NonNull Recipient recipient)
|
|
throws IOException, PaymentsAddressException
|
|
{
|
|
ProfileKey profileKey;
|
|
try {
|
|
profileKey = getProfileKey(recipient);
|
|
} catch (IOException e) {
|
|
Log.w(TAG, "Profile key not available for " + recipient.getId());
|
|
throw new PaymentsAddressException(PaymentsAddressException.Code.NO_PROFILE_KEY);
|
|
}
|
|
ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE);
|
|
SignalServiceProfile profile = profileAndCredential.getProfile();
|
|
byte[] encryptedPaymentsAddress = profile.getPaymentAddress();
|
|
|
|
if (encryptedPaymentsAddress == null) {
|
|
Log.w(TAG, "Payments not enabled for " + recipient.getId());
|
|
throw new PaymentsAddressException(PaymentsAddressException.Code.NOT_ENABLED);
|
|
}
|
|
|
|
try {
|
|
IdentityKey identityKey = new IdentityKey(Base64.decode(profileAndCredential.getProfile().getIdentityKey()), 0);
|
|
ProfileCipher profileCipher = new ProfileCipher(profileKey);
|
|
byte[] decrypted = profileCipher.decryptWithLength(encryptedPaymentsAddress);
|
|
SignalServiceProtos.PaymentAddress paymentAddress = SignalServiceProtos.PaymentAddress.parseFrom(decrypted);
|
|
byte[] bytes = MobileCoinPublicAddressProfileUtil.verifyPaymentsAddress(paymentAddress, identityKey);
|
|
MobileCoinPublicAddress mobileCoinPublicAddress = MobileCoinPublicAddress.fromBytes(bytes);
|
|
|
|
if (mobileCoinPublicAddress == null) {
|
|
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS);
|
|
}
|
|
|
|
return mobileCoinPublicAddress;
|
|
} catch (InvalidCiphertextException | IOException e) {
|
|
Log.w(TAG, "Could not decrypt payments address, ProfileKey may be outdated for " + recipient.getId(), e);
|
|
throw new PaymentsAddressException(PaymentsAddressException.Code.COULD_NOT_DECRYPT);
|
|
} catch (InvalidKeyException e) {
|
|
Log.w(TAG, "Could not verify payments address due to bad identity key " + recipient.getId(), e);
|
|
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS_SIGNATURE);
|
|
}
|
|
}
|
|
|
|
private static ProfileKey getProfileKey(@NonNull Recipient recipient) throws IOException {
|
|
byte[] profileKeyBytes = recipient.getProfileKey();
|
|
|
|
if (profileKeyBytes == null) {
|
|
Log.w(TAG, "Profile key unknown for " + recipient.getId());
|
|
throw new IOException("No profile key");
|
|
}
|
|
|
|
ProfileKey profileKey;
|
|
try {
|
|
profileKey = new ProfileKey(profileKeyBytes);
|
|
} catch (InvalidInputException e) {
|
|
Log.w(TAG, "Profile key invalid for " + recipient.getId());
|
|
throw new IOException("Invalid profile key");
|
|
}
|
|
return profileKey;
|
|
}
|
|
|
|
/**
|
|
* Uploads the profile based on all state that's written to disk, except we'll use the provided
|
|
* list of badges instead. This is useful when you want to ensure that the profile has been uploaded
|
|
* successfully before persisting the change to disk.
|
|
*/
|
|
public static void uploadProfileWithBadges(@NonNull Context context, @NonNull List<Badge> badges) throws IOException {
|
|
Log.d(TAG, "uploadProfileWithBadges()");
|
|
uploadProfile(Recipient.self().getProfileName(),
|
|
Optional.ofNullable(Recipient.self().getAbout()).orElse(""),
|
|
Optional.ofNullable(Recipient.self().getAboutEmoji()).orElse(""),
|
|
getSelfPaymentsAddressProtobuf(),
|
|
AvatarUploadParams.unchanged(AvatarHelper.hasAvatar(context, Recipient.self().getId())),
|
|
badges);
|
|
}
|
|
|
|
/**
|
|
* Uploads the profile based on all state that's written to disk, except we'll use the provided
|
|
* profile name instead. This is useful when you want to ensure that the profile has been uploaded
|
|
* successfully before persisting the change to disk.
|
|
*/
|
|
public static void uploadProfileWithName(@NonNull Context context, @NonNull ProfileName profileName) throws IOException {
|
|
Log.d(TAG, "uploadProfileWithName()");
|
|
try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) {
|
|
uploadProfile(profileName,
|
|
Optional.ofNullable(Recipient.self().getAbout()).orElse(""),
|
|
Optional.ofNullable(Recipient.self().getAboutEmoji()).orElse(""),
|
|
getSelfPaymentsAddressProtobuf(),
|
|
AvatarUploadParams.unchanged(AvatarHelper.hasAvatar(context, Recipient.self().getId())),
|
|
Recipient.self().getBadges());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads the profile based on all state that's written to disk, except we'll use the provided
|
|
* about/emoji instead. This is useful when you want to ensure that the profile has been uploaded
|
|
* successfully before persisting the change to disk.
|
|
*/
|
|
public static void uploadProfileWithAbout(@NonNull Context context, @NonNull String about, @NonNull String emoji) throws IOException {
|
|
Log.d(TAG, "uploadProfileWithAbout()");
|
|
try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) {
|
|
uploadProfile(Recipient.self().getProfileName(),
|
|
about,
|
|
emoji,
|
|
getSelfPaymentsAddressProtobuf(),
|
|
AvatarUploadParams.unchanged(AvatarHelper.hasAvatar(context, Recipient.self().getId())),
|
|
Recipient.self().getBadges());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads the profile based on all state that's already written to disk.
|
|
*/
|
|
public static void uploadProfile(@NonNull Context context) throws IOException {
|
|
Log.d(TAG, "uploadProfile()");
|
|
try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) {
|
|
uploadProfileWithAvatar(avatar);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads the profile based on all state that's written to disk, except we'll use the provided
|
|
* avatar instead. This is useful when you want to ensure that the profile has been uploaded
|
|
* successfully before persisting the change to disk.
|
|
*/
|
|
public static void uploadProfileWithAvatar(@Nullable StreamDetails avatar) throws IOException {
|
|
Log.d(TAG, "uploadProfileWithAvatar()");
|
|
uploadProfile(Recipient.self().getProfileName(),
|
|
Optional.ofNullable(Recipient.self().getAbout()).orElse(""),
|
|
Optional.ofNullable(Recipient.self().getAboutEmoji()).orElse(""),
|
|
getSelfPaymentsAddressProtobuf(),
|
|
AvatarUploadParams.forAvatar(avatar),
|
|
Recipient.self().getBadges());
|
|
}
|
|
|
|
/**
|
|
* Attempts to update just the expiring profile key credential with a new one. If unable, an empty optional is returned.
|
|
*
|
|
* Note: It will try to find missing profile key credentials from the server and persist locally.
|
|
*/
|
|
public static Optional<ExpiringProfileKeyCredential> updateExpiringProfileKeyCredential(@NonNull Recipient recipient) throws IOException {
|
|
ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
|
|
|
|
if (profileKey != null) {
|
|
Log.i(TAG, String.format("Updating profile key credential on recipient %s, fetching", recipient.getId()));
|
|
|
|
Optional<ExpiringProfileKeyCredential> profileKeyCredentialOptional = ApplicationDependencies.getSignalServiceAccountManager()
|
|
.resolveProfileKeyCredential(recipient.requireServiceId(), profileKey, Locale.getDefault());
|
|
|
|
if (profileKeyCredentialOptional.isPresent()) {
|
|
boolean updatedProfileKey = SignalDatabase.recipients().setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get());
|
|
|
|
if (!updatedProfileKey) {
|
|
Log.w(TAG, String.format("Failed to update the profile key credential on recipient %s", recipient.getId()));
|
|
} else {
|
|
Log.i(TAG, String.format("Got new profile key credential for recipient %s", recipient.getId()));
|
|
return profileKeyCredentialOptional;
|
|
}
|
|
}
|
|
}
|
|
|
|
return Optional.empty();
|
|
}
|
|
|
|
private static void uploadProfile(@NonNull ProfileName profileName,
|
|
@Nullable String about,
|
|
@Nullable String aboutEmoji,
|
|
@Nullable SignalServiceProtos.PaymentAddress paymentsAddress,
|
|
@NonNull AvatarUploadParams avatar,
|
|
@NonNull List<Badge> badges)
|
|
throws IOException
|
|
{
|
|
List<String> badgeIds = badges.stream()
|
|
.filter(Badge::getVisible)
|
|
.map(Badge::getId)
|
|
.collect(Collectors.toList());
|
|
|
|
Log.d(TAG, "Uploading " + (!profileName.isEmpty() ? "non-" : "") + "empty profile name.");
|
|
Log.d(TAG, "Uploading " + (!Util.isEmpty(about) ? "non-" : "") + "empty about.");
|
|
Log.d(TAG, "Uploading " + (!Util.isEmpty(aboutEmoji) ? "non-" : "") + "empty emoji.");
|
|
Log.d(TAG, "Uploading " + (paymentsAddress != null ? "non-" : "") + "empty payments address.");
|
|
Log.d(TAG, "Uploading " + ((!badgeIds.isEmpty()) ? "non-" : "") + "empty badge list.");
|
|
|
|
if (avatar.keepTheSame) {
|
|
Log.d(TAG, "Leaving avatar unchanged. We think we " + (avatar.hasAvatar ? "" : "do not ") + "have one.");
|
|
} else {
|
|
Log.d(TAG, "Uploading " + (avatar.stream != null && avatar.stream.getLength() != 0 ? "non-" : "") + "empty avatar.");
|
|
}
|
|
|
|
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
|
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
|
String avatarPath = accountManager.setVersionedProfile(SignalStore.account().requireAci(),
|
|
profileKey,
|
|
profileName.serialize(),
|
|
about,
|
|
aboutEmoji,
|
|
Optional.ofNullable(paymentsAddress),
|
|
avatar,
|
|
badgeIds).orElse(null);
|
|
SignalStore.registrationValues().markHasUploadedProfile();
|
|
if (!avatar.keepTheSame) {
|
|
SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath);
|
|
}
|
|
ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob());
|
|
}
|
|
|
|
private static @Nullable SignalServiceProtos.PaymentAddress getSelfPaymentsAddressProtobuf() {
|
|
if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
|
|
return null;
|
|
} else {
|
|
IdentityKeyPair identityKeyPair = SignalStore.account().getAciIdentityKey();
|
|
MobileCoinPublicAddress publicAddress = ApplicationDependencies.getPayments()
|
|
.getWallet()
|
|
.getMobileCoinPublicAddress();
|
|
|
|
return MobileCoinPublicAddressProfileUtil.signPaymentsAddress(publicAddress.serialize(), identityKeyPair);
|
|
}
|
|
}
|
|
|
|
private static Optional<UnidentifiedAccess> getUnidentifiedAccess(@NonNull Context context, @NonNull Recipient recipient) {
|
|
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient, false);
|
|
|
|
if (unidentifiedAccess.isPresent()) {
|
|
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
|
|
}
|
|
|
|
return Optional.empty();
|
|
}
|
|
|
|
private static @NonNull SignalServiceAddress toSignalServiceAddress(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
|
if (recipient.getRegistered() == RecipientDatabase.RegisteredState.NOT_REGISTERED) {
|
|
if (recipient.hasServiceId()) {
|
|
return new SignalServiceAddress(recipient.requireServiceId(), recipient.getE164().orElse(null));
|
|
} else {
|
|
throw new IOException(recipient.getId() + " not registered!");
|
|
}
|
|
} else {
|
|
return RecipientUtil.toSignalServiceAddress(context, recipient);
|
|
}
|
|
}
|
|
}
|