Convert remaining profile apis to use WebSockets and remove REST fallback.

This commit is contained in:
Cody Henthorne
2025-03-14 18:37:20 -04:00
parent c66819449d
commit d3f622478f
16 changed files with 197 additions and 434 deletions

View File

@@ -53,6 +53,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
@@ -342,6 +343,9 @@ object AppDependencies {
val certificateApi: CertificateApi
get() = networkModule.certificateApi
val profileApi: ProfileApi
get() = networkModule.profileApi
@JvmStatic
val okHttpClient: OkHttpClient
get() = networkModule.okHttpClient
@@ -398,7 +402,7 @@ object AppDependencies {
fun provideExoPlayerPool(): SimpleExoPlayerPool
fun provideAndroidCallAudioManager(): AudioManagerCompat
fun provideDonationsService(pushServiceSocket: PushServiceSocket): DonationsService
fun provideProfileService(profileOperations: ClientZkProfileOperations, signalServiceMessageReceiver: SignalServiceMessageReceiver, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService
fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService
fun provideDeadlockDetector(): DeadlockDetector
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
fun provideScheduledMessageManager(): ScheduledMessageManager
@@ -421,5 +425,6 @@ object AppDependencies {
fun provideMessageApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): MessageApi
fun provideProvisioningApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): ProvisioningApi
fun provideCertificateApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CertificateApi
fun provideProfileApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ProfileApi
}
}

View File

@@ -93,6 +93,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.link.LinkDeviceApi;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.payments.PaymentsApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
@@ -448,11 +449,10 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
@Override
public @NonNull ProfileService provideProfileService(@NonNull ClientZkProfileOperations clientZkProfileOperations,
@NonNull SignalServiceMessageReceiver receiver,
@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket,
@NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket)
{
return new ProfileService(clientZkProfileOperations, receiver, authWebSocket, unauthWebSocket);
return new ProfileService(clientZkProfileOperations, authWebSocket, unauthWebSocket);
}
@Override
@@ -547,6 +547,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new CertificateApi(authWebSocket);
}
@Override
public @NonNull ProfileApi provideProfileApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull PushServiceSocket pushServiceSocket) {
return new ProfileApi(authWebSocket, pushServiceSocket);
}
@VisibleForTesting
static class DynamicCredentialsProvider implements CredentialsProvider {

View File

@@ -38,6 +38,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
@@ -136,7 +137,7 @@ class NetworkDependenciesModule(
}
val profileService: ProfileService by lazy {
provider.provideProfileService(groupsV2Operations.profileOperations, signalServiceMessageReceiver, authWebSocket, unauthWebSocket)
provider.provideProfileService(groupsV2Operations.profileOperations, authWebSocket, unauthWebSocket)
}
val donationsService: DonationsService by lazy {
@@ -203,6 +204,10 @@ class NetworkDependenciesModule(
provider.provideCertificateApi(authWebSocket)
}
val profileApi: ProfileApi by lazy {
provider.provideProfileApi(authWebSocket, pushServiceSocket)
}
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor())

View File

@@ -16,6 +16,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.storage.StorageServiceApi
@@ -67,6 +68,11 @@ object SignalNetwork {
val payments: PaymentsApi
get() = AppDependencies.paymentsApi
@JvmStatic
@get:JvmName("profile")
val profile: ProfileApi
get() = AppDependencies.profileApi
val provisioning: ProvisioningApi
get() = AppDependencies.provisioningApi

View File

@@ -28,6 +28,7 @@ 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.net.SignalNetwork;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddressProfileUtil;
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
@@ -35,7 +36,8 @@ 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.NetworkResult;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
@@ -322,8 +324,7 @@ public final class ProfileUtil {
if (profileKey != null) {
Log.i(TAG, String.format("Updating profile key credential on recipient %s, fetching", recipient.getId()));
Optional<ExpiringProfileKeyCredential> profileKeyCredentialOptional = AppDependencies.getSignalServiceAccountManager()
.resolveProfileKeyCredential(recipient.requireAci(), profileKey, Locale.getDefault());
Optional<ExpiringProfileKeyCredential> profileKeyCredentialOptional = retrieveProfileSync(AppDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL, false).getExpiringProfileKeyCredential();
if (profileKeyCredentialOptional.isPresent()) {
boolean updatedProfileKey = SignalDatabase.recipients().setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get());
@@ -365,17 +366,19 @@ public final class ProfileUtil {
Log.d(TAG, "Uploading " + (avatar.stream != null && avatar.stream.getLength() != 0 ? "non-" : "") + "empty avatar.");
}
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
SignalServiceAccountManager accountManager = AppDependencies.getSignalServiceAccountManager();
String avatarPath = accountManager.setVersionedProfile(SignalStore.account().requireAci(),
profileKey,
profileName.serialize(),
about,
aboutEmoji,
Optional.ofNullable(paymentsAddress),
avatar,
badgeIds,
SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()).orElse(null);
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
NetworkResult<String> result = SignalNetwork.profile().setVersionedProfile(SignalStore.account().requireAci(),
profileKey,
profileName.serialize(),
about,
aboutEmoji,
paymentsAddress,
avatar,
badgeIds,
SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled());
String avatarPath = NetworkResultUtil.toSetProfileLegacy(result);
SignalStore.registration().setHasUploadedProfile(true);
if (!avatar.keepTheSame) {
SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath, false);

View File

@@ -47,6 +47,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
@@ -192,7 +193,6 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
override fun provideProfileService(
profileOperations: ClientZkProfileOperations,
signalServiceMessageReceiver: SignalServiceMessageReceiver,
authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket
): ProfileService {
@@ -286,4 +286,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
override fun provideCertificateApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CertificateApi {
return mockk(relaxed = true)
}
override fun provideProfileApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ProfileApi {
return mockk(relaxed = true)
}
}

View File

@@ -19,6 +19,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatched
import org.whispersystems.signalservice.internal.push.exceptions.GroupStaleDevicesException
import org.whispersystems.signalservice.internal.push.exceptions.InvalidUnidentifiedAccessHeaderException
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException
import java.io.IOException
import java.util.Optional
@@ -154,4 +155,30 @@ object NetworkResultUtil {
}
}
}
/**
* Convert a [NetworkResult] into typed exceptions expected during setting the user's profile.
*/
@JvmStatic
@Throws(AuthorizationFailedException::class, PaymentsRegionException::class, RateLimitException::class, IOException::class)
fun toSetProfileLegacy(result: NetworkResult<String?>): String? {
return when (result) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> {
throw when (val error = result.throwable) {
is IOException, is RuntimeException -> error
else -> RuntimeException(error)
}
}
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> {
throw when (result.code) {
401 -> AuthorizationFailedException(result.code, "Authorization failed!")
403 -> PaymentsRegionException(result.code)
413, 429 -> RateLimitException(result.code, "Rate Limited", Optional.ofNullable(result.header("retry-after")?.toLongOrNull()))
else -> result.exception
}
}
}
}
}

View File

@@ -7,44 +7,25 @@
package org.whispersystems.signalservice.api;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2;
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV3;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.push.PaymentAddress;
import org.whispersystems.signalservice.internal.push.ProfileAvatarData;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.push.RemoteConfigResponse;
import org.whispersystems.signalservice.internal.push.WhoAmIResponse;
import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory;
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -149,69 +130,6 @@ public class SignalServiceAccountManager {
this.pushServiceSocket.pingStorageService();
}
/**
* @return The avatar URL path, if one was written.
*/
public Optional<String> setVersionedProfile(ACI aci,
ProfileKey profileKey,
String name,
String about,
String aboutEmoji,
Optional<PaymentAddress> paymentsAddress,
AvatarUploadParams avatar,
List<String> visibleBadgeIds,
boolean phoneNumberSharing)
throws IOException
{
if (name == null) name = "";
ProfileCipher profileCipher = new ProfileCipher(profileKey);
byte[] ciphertextName = profileCipher.encryptString(name, ProfileCipher.getTargetNameLength(name));
byte[] ciphertextAbout = profileCipher.encryptString(about, ProfileCipher.getTargetAboutLength(about));
byte[] ciphertextEmoji = profileCipher.encryptString(aboutEmoji, ProfileCipher.EMOJI_PADDED_LENGTH);
byte[] ciphertextMobileCoinAddress = paymentsAddress.map(address -> profileCipher.encryptWithLength(address.encode(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE)).orElse(null);
byte[] cipherTextPhoneNumberSharing = profileCipher.encryptBoolean(phoneNumberSharing);
ProfileAvatarData profileAvatarData = null;
if (avatar.stream != null && !avatar.keepTheSame) {
profileAvatarData = new ProfileAvatarData(avatar.stream.getStream(),
ProfileCipherOutputStream.getCiphertextLength(avatar.stream.getLength()),
avatar.stream.getContentType(),
new ProfileCipherOutputStreamFactory(profileKey));
}
return this.pushServiceSocket.writeProfile(new SignalServiceProfileWrite(profileKey.getProfileKeyVersion(aci.getLibSignalAci()).serialize(),
ciphertextName,
ciphertextAbout,
ciphertextEmoji,
ciphertextMobileCoinAddress,
cipherTextPhoneNumberSharing,
avatar.hasAvatar,
avatar.keepTheSame,
profileKey.getCommitment(aci.getLibSignalAci()).serialize(),
visibleBadgeIds),
profileAvatarData);
}
public Optional<ExpiringProfileKeyCredential> resolveProfileKeyCredential(ACI serviceId, ProfileKey profileKey, Locale locale)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
try {
ProfileAndCredential credential = this.pushServiceSocket.retrieveVersionedProfileAndCredential(serviceId, profileKey, SealedSenderAccess.NONE, locale).get(10, TimeUnit.SECONDS);
return credential.getExpiringProfileKeyCredential();
} catch (InterruptedException | TimeoutException e) {
throw new PushNetworkException(e);
} catch (ExecutionException e) {
if (e.getCause() instanceof NonSuccessfulResponseCodeException) {
throw (NonSuccessfulResponseCodeException) e.getCause();
} else if (e.getCause() instanceof PushNetworkException) {
throw (PushNetworkException) e.getCause();
} else {
throw new PushNetworkException(e);
}
}
}
public void cancelInFlightRequests() {
this.pushServiceSocket.cancelInFlightRequests();
}

View File

@@ -87,42 +87,6 @@ public class SignalServiceMessageReceiver {
return retrieveAttachment(pointer, destination, maxSizeBytes, null).getDataStream();
}
public ListenableFuture<ProfileAndCredential> retrieveProfile(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
@Nullable SealedSenderAccess sealedSenderAccess,
SignalServiceProfile.RequestType requestType,
Locale locale)
{
if (profileKey.isPresent()) {
ACI aci;
if (address.getServiceId() instanceof ACI) {
aci = (ACI) address.getServiceId();
} else {
// We shouldn't ever have a profile key for a non-ACI.
SettableFuture<ProfileAndCredential> result = new SettableFuture<>();
result.setException(new ClassCastException("retrieving a versioned profile requires an ACI"));
return result;
}
if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
return socket.retrieveVersionedProfileAndCredential(aci, profileKey.get(), sealedSenderAccess, locale);
} else {
return FutureTransformers.map(socket.retrieveVersionedProfile(aci, profileKey.get(), sealedSenderAccess, locale), profile -> {
return new ProfileAndCredential(profile,
SignalServiceProfile.RequestType.PROFILE,
Optional.empty());
});
}
} else {
return FutureTransformers.map(socket.retrieveProfile(address, sealedSenderAccess, locale), profile -> {
return new ProfileAndCredential(profile,
SignalServiceProfile.RequestType.PROFILE,
Optional.empty());
});
}
}
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes)
throws IOException
{
@@ -137,10 +101,6 @@ public class SignalServiceMessageReceiver {
return new FileInputStream(destination);
}
public Single<ServiceResponse<IdentityCheckResponse>> performIdentityCheck(@Nonnull IdentityCheckRequest request, @Nonnull ResponseMapper<IdentityCheckResponse> responseMapper) {
return socket.performIdentityCheck(request, responseMapper);
}
/**
* Retrieves a SignalServiceAttachment. The encrypted data is written to @{code destination}, and then an {@link InputStream} is returned that decrypts the
* contents of the destination file, giving you access to the plaintext content.

View File

@@ -16,6 +16,7 @@ import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
@@ -105,7 +106,7 @@ public class ProfileCipher {
/**
* Encrypts a string's UTF bytes representation.
*/
public byte[] encryptString(String input, int paddedLength) {
public byte[] encryptString(@Nonnull String input, int paddedLength) {
return encrypt(input.getBytes(StandardCharsets.UTF_8), paddedLength);
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.profiles
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.crypto.ProfileCipher
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.push.PaymentAddress
import org.whispersystems.signalservice.internal.push.ProfileAvatarData
import org.whispersystems.signalservice.internal.push.ProfileAvatarUploadAttributes
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.util.JsonUtil
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
/**
* Endpoints to interact with profiles. Currently contains only setting profile but will
* be eventual home for functionality in [ProfileService].
*/
class ProfileApi(
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket
) {
/**
* Set/update the user's profile on the service, including uploading an avatar if one is provided.
*
* PUT /v1/profile
* - 200: Success
* - 401: Authorization failure
* - 403: Payment region not allowed
*
* @return The avatar URL path, if one was written.
*/
fun setVersionedProfile(
aci: ServiceId.ACI,
profileKey: ProfileKey,
name: String?,
about: String?,
aboutEmoji: String?,
paymentsAddress: PaymentAddress?,
avatar: AvatarUploadParams,
visibleBadgeIds: List<String>,
phoneNumberSharing: Boolean
): NetworkResult<String?> {
val profileCipher = ProfileCipher(profileKey)
val profileWrite = SignalServiceProfileWrite(
version = profileKey.getProfileKeyVersion(aci.libSignalAci).serialize(),
name = profileCipher.encryptString(name ?: "", ProfileCipher.getTargetNameLength(name)),
about = profileCipher.encryptString(about ?: "", ProfileCipher.getTargetAboutLength(about)),
aboutEmoji = profileCipher.encryptString(aboutEmoji ?: "", ProfileCipher.EMOJI_PADDED_LENGTH),
paymentAddress = paymentsAddress?.let { profileCipher.encryptWithLength(it.encode(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE) },
phoneNumberSharing = profileCipher.encryptBoolean(phoneNumberSharing),
avatar = avatar.hasAvatar,
sameAvatar = avatar.keepTheSame,
commitment = profileKey.getCommitment(aci.libSignalAci).serialize(),
badgeIds = visibleBadgeIds
)
val profileAvatarData: ProfileAvatarData? = if (avatar.stream != null && !avatar.keepTheSame) {
ProfileAvatarData(
avatar.stream.stream,
ProfileCipherOutputStream.getCiphertextLength(avatar.stream.length),
avatar.stream.contentType,
ProfileCipherOutputStreamFactory(profileKey)
)
} else {
null
}
val request = WebSocketRequestMessage.put("/v1/profile", profileWrite)
return NetworkResult.fromWebSocketRequest(authWebSocket, request, String::class)
.then { fromResponse: String ->
if (profileWrite.avatar && profileAvatarData != null) {
val formAttributes = JsonUtil.fromJsonResponse(fromResponse, ProfileAvatarUploadAttributes::class.java)
pushServiceSocket.uploadProfileAvatar(formAttributes, profileAvatarData)
} else {
NetworkResult.Success(null)
}
}
}
}

View File

@@ -1,60 +0,0 @@
package org.whispersystems.signalservice.api.profiles;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class SignalServiceProfileWrite {
@JsonProperty
private String version;
@JsonProperty
private byte[] name;
@JsonProperty
private byte[] about;
@JsonProperty
private byte[] aboutEmoji;
@JsonProperty
private byte[] paymentAddress;
@JsonProperty
private byte[] phoneNumberSharing;
@JsonProperty
private boolean avatar;
@JsonProperty
private boolean sameAvatar;
@JsonProperty
private byte[] commitment;
@JsonProperty
private List<String> badgeIds;
@JsonCreator
public SignalServiceProfileWrite(){
}
public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, byte[] phoneNumberSharing, boolean hasAvatar, boolean sameAvatar, byte[] commitment, List<String> badgeIds) {
this.version = version;
this.name = name;
this.about = about;
this.aboutEmoji = aboutEmoji;
this.paymentAddress = paymentAddress;
this.phoneNumberSharing = phoneNumberSharing;
this.avatar = hasAvatar;
this.sameAvatar = sameAvatar;
this.commitment = commitment;
this.badgeIds = badgeIds;
}
public boolean hasAvatar() {
return avatar;
}
}

View File

@@ -0,0 +1,16 @@
package org.whispersystems.signalservice.api.profiles
import com.fasterxml.jackson.annotation.JsonProperty
class SignalServiceProfileWrite(
@JsonProperty val version: String,
@JsonProperty val name: ByteArray,
@JsonProperty val about: ByteArray,
@JsonProperty val aboutEmoji: ByteArray,
@JsonProperty val paymentAddress: ByteArray?,
@JsonProperty val phoneNumberSharing: ByteArray,
@JsonProperty val avatar: Boolean,
@JsonProperty val sameAvatar: Boolean,
@JsonProperty val commitment: ByteArray,
@JsonProperty val badgeIds: List<String>
)

View File

@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.internal.ServiceResponse;
@@ -38,8 +37,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -51,7 +48,7 @@ import io.reactivex.rxjava3.core.Single;
/**
* Provide Profile-related API services, encapsulating the logic to make the request, parse the response,
* and fallback to appropriate WebSocket or RESTful alternatives.
* and fallback to appropriate WebSocket alternatives.
*/
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public final class ProfileService {
@@ -59,17 +56,14 @@ public final class ProfileService {
private static final String TAG = ProfileService.class.getSimpleName();
private final ClientZkProfileOperations clientZkProfileOperations;
private final SignalServiceMessageReceiver receiver;
private final SignalWebSocket.AuthenticatedWebSocket authWebSocket;
private final SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket;
public ProfileService(ClientZkProfileOperations clientZkProfileOperations,
SignalServiceMessageReceiver receiver,
SignalWebSocket.AuthenticatedWebSocket authWebSocket,
SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket)
{
this.clientZkProfileOperations = clientZkProfileOperations;
this.receiver = receiver;
this.authWebSocket = authWebSocket;
this.unauthWebSocket = unauthWebSocket;
}
@@ -123,7 +117,6 @@ public final class ProfileService {
if (sealedSenderAccess == null) {
return authWebSocket.request(requestMessage)
.map(responseMapper::map)
.onErrorResumeNext(t -> getProfileRestFallback(address, profileKey, null, requestType, locale))
.onErrorReturn(ServiceResponse::forUnknownError);
} else {
return unauthWebSocket.request(requestMessage, sealedSenderAccess)
@@ -135,7 +128,6 @@ public final class ProfileService {
}
})
.map(responseMapper::map)
.onErrorResumeNext(t -> getProfileRestFallback(address, profileKey, sealedSenderAccess, requestType, locale))
.onErrorReturn(ServiceResponse::forUnknownError);
}
}
@@ -159,53 +151,9 @@ public final class ProfileService {
return unauthWebSocket.request(builder.build())
.map(responseMapper::map)
.onErrorResumeNext(t -> performIdentityCheckRestFallback(request, responseMapper))
.onErrorReturn(ServiceResponse::forUnknownError);
}
private Single<ServiceResponse<ProfileAndCredential>> getProfileRestFallback(@Nonnull SignalServiceAddress address,
@Nonnull Optional<ProfileKey> profileKey,
@Nullable SealedSenderAccess sealedSenderAccess,
@Nonnull SignalServiceProfile.RequestType requestType,
@Nonnull Locale locale)
{
return Single.fromFuture(receiver.retrieveProfile(address, profileKey, sealedSenderAccess, requestType, locale), 10, TimeUnit.SECONDS)
.onErrorResumeNext(t -> {
Throwable error;
if (t instanceof ExecutionException && t.getCause() != null) {
error = t.getCause();
} else {
error = t;
}
if (error instanceof AuthorizationFailedException) {
return Single.fromFuture(receiver.retrieveProfile(address, profileKey, null, requestType, locale), 10, TimeUnit.SECONDS);
} else {
return Single.error(t);
}
})
.map(p -> ServiceResponse.forResult(p, 0, null));
}
private @NonNull Single<ServiceResponse<IdentityCheckResponse>> performIdentityCheckRestFallback(@Nonnull IdentityCheckRequest request,
@Nonnull ResponseMapper<IdentityCheckResponse> responseMapper) {
return receiver.performIdentityCheck(request, responseMapper)
.onErrorResumeNext(t -> {
Throwable error;
if (t instanceof ExecutionException && t.getCause() != null) {
error = t.getCause();
} else {
error = t;
}
if (error instanceof AuthorizationFailedException) {
return receiver.performIdentityCheck(request, responseMapper);
} else {
return Single.error(t);
}
});
}
/**
* Maps the API {@link SignalServiceProfile} model into the desired {@link ProfileAndCredential} domain model.
*/

View File

@@ -11,19 +11,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.squareup.wire.Message;
import org.signal.core.util.Base64;
import org.signal.core.util.concurrent.FutureTransformers;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.logging.Log;
import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyVersion;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
@@ -36,6 +27,7 @@ import org.signal.storageservice.protos.groups.GroupExternalCredential;
import org.signal.storageservice.protos.groups.GroupJoinInfo;
import org.signal.storageservice.protos.groups.GroupResponse;
import org.signal.storageservice.protos.groups.Member;
import org.whispersystems.signalservice.api.NetworkResult;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
@@ -43,10 +35,6 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.calls.CallingResponse;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
@@ -89,7 +77,6 @@ import org.whispersystems.signalservice.api.svr.Svr3Credentials;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
import org.whispersystems.signalservice.api.util.TlsProxySocketFactory;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
@@ -106,9 +93,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentRec
import org.whispersystems.signalservice.internal.push.exceptions.InvalidUnidentifiedAccessHeaderException;
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException;
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
import org.whispersystems.signalservice.internal.push.http.AcceptLanguagesUtil;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody;
import org.whispersystems.signalservice.internal.push.http.NoCipherOutputStreamFactory;
@@ -122,7 +107,6 @@ import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -158,10 +142,7 @@ import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.ConnectionPool;
import okhttp3.ConnectionSpec;
import okhttp3.Dns;
@@ -189,9 +170,6 @@ public class PushServiceSocket {
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s";
private static final String PROFILE_PATH = "/v1/profile/%s";
private static final String PROFILE_BATCH_CHECK_PATH = "/v1/profile/identity_check/batch";
private static final String ATTACHMENT_KEY_DOWNLOAD_PATH = "attachments/%s";
private static final String ATTACHMENT_ID_DOWNLOAD_PATH = "attachments/%d";
private static final String AVATAR_UPLOAD_PATH = "";
@@ -523,72 +501,6 @@ public class PushServiceSocket {
return output.toByteArray();
}
public ListenableFuture<SignalServiceProfile> retrieveProfile(SignalServiceAddress target, @Nullable SealedSenderAccess sealedSenderAccess, Locale locale) {
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), sealedSenderAccess);
return FutureTransformers.map(response, body -> {
try {
return JsonUtil.fromJson(body, SignalServiceProfile.class);
} catch (IOException e) {
Log.w(TAG, e);
throw new MalformedResponseException("Unable to parse entity", e);
}
});
}
public ListenableFuture<ProfileAndCredential> retrieveVersionedProfileAndCredential(ACI target, ProfileKey profileKey, @Nullable SealedSenderAccess sealedSenderAccess, Locale locale) {
ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target.getLibSignalAci());
ProfileKeyCredentialRequestContext requestContext = clientZkProfileOperations.createProfileKeyCredentialRequestContext(random, target.getLibSignalAci(), profileKey);
ProfileKeyCredentialRequest request = requestContext.getRequest();
String version = profileKeyIdentifier.serialize();
String credentialRequest = Hex.toStringCondensed(request.serialize());
String subPath = String.format("%s/%s/%s?credentialType=expiringProfileKey", target, version, credentialRequest);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), sealedSenderAccess);
return FutureTransformers.map(response, body -> formatProfileAndCredentialBody(requestContext, body));
}
private ProfileAndCredential formatProfileAndCredentialBody(ProfileKeyCredentialRequestContext requestContext, String body)
throws MalformedResponseException
{
try {
SignalServiceProfile signalServiceProfile = JsonUtil.fromJson(body, SignalServiceProfile.class);
try {
ExpiringProfileKeyCredential expiringProfileKeyCredential = signalServiceProfile.getExpiringProfileKeyCredentialResponse() != null
? clientZkProfileOperations.receiveExpiringProfileKeyCredential(requestContext, signalServiceProfile.getExpiringProfileKeyCredentialResponse())
: null;
return new ProfileAndCredential(signalServiceProfile, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL, Optional.ofNullable(expiringProfileKeyCredential));
} catch (VerificationFailedException e) {
Log.w(TAG, "Failed to verify credential.", e);
return new ProfileAndCredential(signalServiceProfile, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL, Optional.empty());
}
} catch (IOException e) {
Log.w(TAG, e);
throw new MalformedResponseException("Unable to parse entity", e);
}
}
public ListenableFuture<SignalServiceProfile> retrieveVersionedProfile(ACI target, ProfileKey profileKey, @Nullable SealedSenderAccess sealedSenderAccess, Locale locale) {
ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target.getLibSignalAci());
String version = profileKeyIdentifier.serialize();
String subPath = String.format("%s/%s", target, version);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), sealedSenderAccess);
return FutureTransformers.map(response, body -> {
try {
return JsonUtil.fromJson(body, SignalServiceProfile.class);
} catch (IOException e) {
Log.w(TAG, e);
throw new MalformedResponseException("Unable to parse entity", e);
}
});
}
public void retrieveProfileAvatar(String path, File destination, long maxSizeBytes)
throws IOException
{
@@ -600,56 +512,21 @@ public class PushServiceSocket {
}
/**
* @return The avatar URL path, if one was written.
* Only used to upload self profile avatar as part of writing the profile to the service.
*
* @return Profile avatar key
*/
public Optional<String> writeProfile(SignalServiceProfileWrite signalServiceProfileWrite, ProfileAvatarData profileAvatar)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
String requestBody = JsonUtil.toJson(signalServiceProfileWrite);
ProfileAvatarUploadAttributes formAttributes;
String response = makeServiceRequest(String.format(PROFILE_PATH, ""),
"PUT",
requestBody,
NO_HEADERS,
PaymentsRegionException::responseCodeHandler,
SealedSenderAccess.NONE);
if (signalServiceProfileWrite.hasAvatar() && profileAvatar != null) {
try {
formAttributes = JsonUtil.fromJson(response, ProfileAvatarUploadAttributes.class);
} catch (IOException e) {
Log.w(TAG, e);
throw new MalformedResponseException("Unable to parse entity", e);
}
public NetworkResult<String> uploadProfileAvatar(ProfileAvatarUploadAttributes formAttributes, ProfileAvatarData profileAvatar) {
return NetworkResult.fromFetch(() -> {
uploadToCdn0(AVATAR_UPLOAD_PATH, formAttributes.getAcl(), formAttributes.getKey(),
formAttributes.getPolicy(), formAttributes.getAlgorithm(),
formAttributes.getCredential(), formAttributes.getDate(),
formAttributes.getSignature(), profileAvatar.getData(),
profileAvatar.getContentType(), profileAvatar.getDataLength(), false,
profileAvatar.getOutputStreamFactory(), null, null);
formAttributes.getPolicy(), formAttributes.getAlgorithm(),
formAttributes.getCredential(), formAttributes.getDate(),
formAttributes.getSignature(), profileAvatar.getData(),
profileAvatar.getContentType(), profileAvatar.getDataLength(), false,
profileAvatar.getOutputStreamFactory(), null, null);
return Optional.of(formAttributes.getKey());
}
return Optional.empty();
}
public Single<ServiceResponse<IdentityCheckResponse>> performIdentityCheck(@Nonnull IdentityCheckRequest request,
@Nonnull ResponseMapper<IdentityCheckResponse> responseMapper)
{
Single<ServiceResponse<IdentityCheckResponse>> requestSingle = Single.fromCallable(() -> {
try (Response response = getServiceConnection(PROFILE_BATCH_CHECK_PATH, "POST", jsonRequestBody(JsonUtil.toJson(request)), Collections.emptyMap(), SealedSenderAccess.NONE, false)) {
String body = response.body() != null ? readBodyString(response.body()): "";
return responseMapper.map(response.code(), body, response::header, false);
}
return formAttributes.getKey();
});
return requestSingle
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.onErrorReturn(ServiceResponse::forUnknownError);
}
public BackupV2AuthCheckResponse checkSvr2AuthCredentials(@Nullable String number, @Nonnull List<String> passwords) throws IOException {
@@ -1446,41 +1323,6 @@ public class PushServiceSocket {
: null;
}
private ListenableFuture<String> submitServiceRequest(String urlFragment,
String method,
String jsonBody,
Map<String, String> headers,
@Nullable SealedSenderAccess sealedSenderAccess)
{
OkHttpClient okHttpClient = buildOkHttpClient(sealedSenderAccess != null);
Call call = okHttpClient.newCall(buildServiceRequest(urlFragment, method, jsonRequestBody(jsonBody), headers, sealedSenderAccess, false));
synchronized (connections) {
connections.add(call);
}
SettableFuture<String> bodyFuture = new SettableFuture<>();
call.enqueue(new Callback() {
@Override
public void onResponse(Call call, Response response) {
try (ResponseBody body = response.body()) {
validateServiceResponse(response);
bodyFuture.set(readBodyString(body));
} catch (IOException e) {
bodyFuture.setException(e);
}
}
@Override
public void onFailure(Call call, IOException e) {
bodyFuture.setException(e);
}
});
return bodyFuture;
}
private Response makeServiceRequest(String urlFragment,
String method,
RequestBody body,

View File

@@ -10,13 +10,4 @@ public final class PaymentsRegionException extends NonSuccessfulResponseCodeExce
public PaymentsRegionException(int code) {
super(code);
}
/**
* Promotes a 403 to this exception type.
*/
public static void responseCodeHandler(int responseCode, ResponseBody body, Function<String, String> getHeader) throws PaymentsRegionException {
if (responseCode == 403) {
throw new PaymentsRegionException(responseCode);
}
}
}