Don't assume presence of phone numbers when checking for prohibited payment regions

This commit is contained in:
Jon Chambers authored and GitHub committed 2026-07-29 15:41:51 -07:00
1 parent 78e7e992da
commit 2f1843d5d2
11 files changed
+242 -60

No files matched your search

@@ -1093,7 +1093,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, chatGenericZkSecretParams, profileBadgeConverter, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, asnInfoProviderSupplier, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, chatGenericZkSecretParams, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, messageDispatcher, Clock.systemUTC()),
new BackupsGrpcService(accountsManager, backupAuthManager, backupMetrics),
new DevicesGrpcService(accountsManager),
@@ -1263,8 +1263,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, accountsManager,
phoneNumberIdentifiers, reportMessageManager, groupZkSecretParams, spamChecker, Clock.systemUTC()),
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator,
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, asnInfoProviderSupplier,
dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator,
groupZkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
@@ -35,7 +35,7 @@ public class DynamicConfiguration {
@JsonProperty
@Valid
private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration();
private DynamicPaymentsConfiguration payments = DynamicPaymentsConfiguration.DEFAULT;
@JsonProperty
@Valid
@@ -6,15 +6,23 @@
package org.whispersystems.textsecuregcm.configuration.dynamic;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import java.util.Collections;
import java.util.List;
public class DynamicPaymentsConfiguration {
public record DynamicPaymentsConfiguration(@JsonProperty @NotNull List<String> disallowedPrefixes,
@JsonProperty @NotNull List<String> disallowedAsnRegions) {
@JsonProperty
private List<String> disallowedPrefixes = Collections.emptyList();
public static DynamicPaymentsConfiguration DEFAULT =
new DynamicPaymentsConfiguration(Collections.emptyList(), Collections.emptyList());
public List<String> getDisallowedPrefixes() {
return disallowedPrefixes;
public DynamicPaymentsConfiguration {
if (disallowedPrefixes == null) {
disallowedPrefixes = DEFAULT.disallowedPrefixes();
}
if (disallowedAsnRegions == null) {
disallowedAsnRegions = DEFAULT.disallowedAsnRegions();
}
}
}
@@ -50,6 +50,7 @@ import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.glassfish.jersey.server.ManagedAsync;
@@ -64,6 +65,7 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialRespons
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;
@@ -80,6 +82,7 @@ import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
@@ -108,6 +111,7 @@ public class ProfileController {
private final RateLimiters rateLimiters;
private final ProfilesManager profilesManager;
private final AccountsManager accountsManager;
private final Supplier<AsnInfoProvider> asnInfoProviderSupplier;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final ProfileBadgeConverter profileBadgeConverter;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
@@ -124,25 +128,27 @@ public class ProfileController {
private static final String DUPLICATE_AUTHENTICATION_COUNTER_NAME = name(ProfileController.class, "duplicateAuthentication");
public ProfileController(
Clock clock,
RateLimiters rateLimiters,
AccountsManager accountsManager,
ProfilesManager profilesManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
ProfileBadgeConverter profileBadgeConverter,
BadgesConfiguration badgesConfiguration,
PostPolicyGenerator policyGenerator,
ServerSecretParams serverSecretParams,
ServerZkProfileOperations zkProfileOperations,
Executor batchIdentityCheckExecutor) {
final Clock clock,
final RateLimiters rateLimiters,
final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final Supplier<AsnInfoProvider> asnInfoProviderSupplier,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final ProfileBadgeConverter profileBadgeConverter,
final BadgesConfiguration badgesConfiguration,
final PostPolicyGenerator policyGenerator,
final ServerSecretParams serverSecretParams,
final ServerZkProfileOperations zkProfileOperations,
final Executor batchIdentityCheckExecutor) {
this.clock = clock;
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.asnInfoProviderSupplier = asnInfoProviderSupplier;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.profileBadgeConverter = profileBadgeConverter;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream()
.collect(Collectors.toMap(BadgeConfiguration::getId, Function.identity()));
this.serverSecretParams = serverSecretParams;
this.zkProfileOperations = zkProfileOperations;
this.policyGenerator = policyGenerator;
@@ -161,10 +167,12 @@ public class ProfileController {
description = "If the request changed the avatar, the response body contains an upload form.")))
@ApiResponse(responseCode = "400", description = "Invalid create profile request.")
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "403", description = "The request contained a payment address, but payments are not supported in the region of the account’s phone number.")
@ApiResponse(responseCode = "403", description = "The request contained a payment address, but payments are not supported in the region of the account’s phone number or the caller's ASN if the account does not have a phone number.")
@ApiResponse(responseCode = "412", description = "The requesting account has the profiles_v2 capability")
@ApiResponse(responseCode = "422", description = "Invalid request format")
public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
public Response setProfile(@Auth AuthenticatedDevice auth,
@NotNull @Valid CreateProfileRequest request,
@Context final ContainerRequestContext requestContext) {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
@@ -176,7 +184,12 @@ public class ProfileController {
final Optional<VersionedProfileV1> currentProfile =
profilesManager.getV1(auth.accountIdentifier(), request.version());
if (request.paymentAddress() != null && request.paymentAddress().length != 0 && ProfileHelper.isPaymentAddressUpdateForbidden(account, Optional.empty(), currentProfile, dynamicConfigurationManager)) {
final String remoteAddress = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
if (request.paymentAddress() != null &&
request.paymentAddress().length != 0 &&
ProfileHelper.isPaymentAddressUpdateForbidden(account, Optional.empty(), currentProfile, remoteAddress, asnInfoProviderSupplier.get(), dynamicConfigurationManager)) {
return Response.status(Response.Status.FORBIDDEN).build();
}
@@ -198,7 +211,7 @@ public class ProfileController {
currentAvatar.ifPresent(profilesManager::deleteAvatar);
}
accountsManager.update(account.getIdentifier(IdentityType.ACI), a -> {
accountsManager.update(account.getAccountIdentifier(), a -> {
final List<AccountBadge> updatedBadges = request.badges()
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))
@@ -16,6 +16,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.errors.FailedPrecondition;
@@ -37,8 +38,7 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialRequest;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
@@ -46,7 +46,6 @@ import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
@@ -63,11 +62,10 @@ import org.whispersystems.textsecuregcm.util.ProfileHelper;
public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
private static final Logger logger = LoggerFactory.getLogger(ProfileGrpcService.class);
private final Clock clock;
private final AccountsManager accountsManager;
private final ProfilesManager profilesManager;
private final Supplier<AsnInfoProvider> asnInfoProviderSupplier;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PostPolicyGenerator policyGenerator;
@@ -83,6 +81,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
final Clock clock,
final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final Supplier<AsnInfoProvider> asnInfoProviderSupplier,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final BadgesConfiguration badgesConfiguration,
final PostPolicyGenerator policyGenerator,
@@ -92,9 +91,10 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
this.clock = clock;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.asnInfoProviderSupplier = asnInfoProviderSupplier;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream()
.collect(Collectors.toMap(BadgeConfiguration::getId, Function.identity()));
this.policyGenerator = policyGenerator;
this.genericServerSecretParams = genericServerSecretParams;
this.profileBadgeConverter = profileBadgeConverter;
@@ -132,7 +132,9 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
final Optional<VersionedProfileV1> maybeV1Profile = profilesManager.getV1(
authenticatedDevice.accountIdentifier(), HexFormat.of().formatHex(version));
if (!request.getPaymentAddress().isEmpty() && ProfileHelper.isPaymentAddressUpdateForbidden(account, maybeProfile, maybeV1Profile, dynamicConfigurationManager)) {
if (!request.getPaymentAddress().isEmpty() &&
ProfileHelper.isPaymentAddressUpdateForbidden(account, maybeProfile, maybeV1Profile, RequestAttributesUtil.getRemoteAddress().getHostAddress(), asnInfoProviderSupplier.get(), dynamicConfigurationManager)) {
return SetProfileResponse.newBuilder()
.setPaymentsForbiddenInRegion(PaymentsForbiddenInRegion.getDefaultInstance())
.build();
@@ -172,7 +174,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
);
try {
profilesManager.set(account.getIdentifier(IdentityType.ACI), v1Profile, profile,
profilesManager.set(account.getAccountIdentifier(), v1Profile, profile,
request.getExpectedCurrentDataHash().isEmpty() ? null : request.getExpectedCurrentDataHash().toByteArray());
} catch (WriteConflictException _) {
@@ -184,7 +186,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
}
try {
accountsManager.updateCurrentProfileVersion(account.getIdentifier(IdentityType.ACI), version, expectedCurrentVersion, a -> {
accountsManager.updateCurrentProfileVersion(account.getAccountIdentifier(), version, expectedCurrentVersion, a -> {
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges,
@@ -235,7 +237,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
request.getAvatarCredentialsRequest().toByteArray());
final AvatarUploadCredentialResponse credentialResponse = credentialRequest.issueCredential(
new ServiceId.Aci(account.getIdentifier(IdentityType.ACI)),
new ServiceId.Aci(account.getAccountIdentifier()),
account.getZkCredentialKey().get(),
Objects.requireNonNull(account.getZkCredentialKeyRotationId()),
clock.instant().truncatedTo(ChronoUnit.DAYS),
@@ -21,8 +21,10 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialRespons
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account;
@@ -41,12 +43,12 @@ public class ProfileHelper {
final Map<String, BadgeConfiguration> badgeConfigurationMap,
final List<String> badgeIds,
final List<AccountBadge> accountBadges) {
LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
final LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
for (final AccountBadge accountBadge : accountBadges) {
existingBadges.putIfAbsent(accountBadge.id(), accountBadge);
}
LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
final LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
for (final String badgeId : badgeIds) {
// duplicate in the list, ignore it
@@ -104,17 +106,27 @@ public class ProfileHelper {
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public static boolean isPaymentAddressUpdateForbidden(
final Account account, final Optional<VersionedProfile> maybeProfile,
final Account account,
final Optional<VersionedProfile> maybeProfile,
final Optional<VersionedProfileV1> maybeV1Profile,
final String ipAddress,
final AsnInfoProvider asnInfoProvider,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
final Optional<byte[]> currentPaymentAddress = maybeProfile.map(VersionedProfile::paymentAddress)
.or(() -> maybeV1Profile.map(VersionedProfileV1::paymentAddress));
final boolean hasDisallowedPrefix = dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration()
.getDisallowedPrefixes().stream()
.anyMatch(prefix -> account.getNumber().startsWith(prefix));
return hasDisallowedPrefix && currentPaymentAddress.filter(a -> a.length != 0).isEmpty();
final DynamicPaymentsConfiguration paymentsConfiguration =
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration();
final boolean hasDisallowedRegion = account.getNumberOptional()
.map(phoneNumber -> paymentsConfiguration.disallowedPrefixes().stream().anyMatch(phoneNumber::startsWith))
.orElseGet(() -> asnInfoProvider.lookup(ipAddress)
.map(asnInfo -> paymentsConfiguration.disallowedAsnRegions().stream()
.anyMatch(region -> region.equalsIgnoreCase(asnInfo.regionCode())))
.orElse(false));
return hasDisallowedRegion && currentPaymentAddress.filter(a -> a.length != 0).isEmpty();
}
@Nullable
@@ -157,8 +157,9 @@ message SetProfileResponse {
// The current data hash did not match the request's expectation, indicating
// another device on the account may have written an update the caller does not know about.
errors.FailedPrecondition expected_data_write_conflict = 2 [(tag.reason) = "expected_data_write_conflict"];
// Payments are not permitted in the account's region, based on its phone number.
// The request should be retried without `payment_address.
// Payments are not permitted in the account's region, based on its phone
// number or the caller's IP address if the account does not have a phone
// number. The request should be retried without `payment_address.
PaymentsForbiddenInRegion payments_forbidden_in_region = 3 [(tag.reason) = "payments_forbidden_in_region"];
// The current version did not match the request's expectation, indicating
// another device on the account may have created a new version the caller does not know about.
@@ -252,7 +252,7 @@ class DynamicConfigurationTest {
final DynamicConfiguration emptyConfig =
DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();
assertTrue(emptyConfig.getPaymentsConfiguration().getDisallowedPrefixes().isEmpty());
assertTrue(emptyConfig.getPaymentsConfiguration().disallowedPrefixes().isEmpty());
}
{
@@ -266,7 +266,7 @@ class DynamicConfigurationTest {
DynamicConfigurationManager.parseConfiguration(paymentsConfigYaml, DynamicConfiguration.class).orElseThrow()
.getPaymentsConfiguration();
assertEquals(List.of("+44"), config.getDisallowedPrefixes());
assertEquals(List.of("+44"), config.disallowedPrefixes());
}
}
@@ -79,6 +79,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
@@ -161,6 +162,7 @@ class ProfileControllerTest {
rateLimiters,
accountsManager,
profilesManager,
() -> AsnInfoProvider.EMPTY,
dynamicConfigurationManager,
new ProfileBadgeConverter() {
@Override
@@ -202,7 +204,7 @@ class ProfileControllerTest {
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());
when(dynamicPaymentsConfiguration.disallowedPrefixes()).thenReturn(Collections.emptyList());
when(rateLimiters.getProfileLimiter()).thenReturn(rateLimiter);
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameRateLimiter);
@@ -212,7 +214,6 @@ class ProfileControllerTest {
when(profileAccount.getIdentityKey(IdentityType.ACI)).thenReturn(ACCOUNT_TWO_IDENTITY_KEY);
when(profileAccount.getIdentityKey(IdentityType.PNI)).thenReturn(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);
when(profileAccount.getAccountIdentifier()).thenReturn(AuthHelper.VALID_UUID_TWO);
when(profileAccount.getIdentifier(IdentityType.ACI)).thenReturn(AuthHelper.VALID_UUID_TWO);
when(profileAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI_TWO);
when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());
when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH));
@@ -223,7 +224,6 @@ class ProfileControllerTest {
capabilitiesAccount = mock(Account.class);
when(capabilitiesAccount.getAccountIdentifier()).thenReturn(AuthHelper.VALID_UUID);
when(capabilitiesAccount.getIdentifier(IdentityType.ACI)).thenReturn(AuthHelper.VALID_UUID);
when(capabilitiesAccount.getIdentityKey(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTITY_KEY);
when(capabilitiesAccount.getIdentityKey(IdentityType.PNI)).thenReturn(ACCOUNT_PHONE_NUMBER_IDENTITY_KEY);
@@ -806,7 +806,7 @@ class ProfileControllerTest {
@Test
void testSetProfilePaymentAddressCountryNotAllowed() throws InvalidInputException {
when(dynamicPaymentsConfiguration.getDisallowedPrefixes())
when(dynamicPaymentsConfiguration.disallowedPrefixes())
.thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3)));
final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));
@@ -836,7 +836,7 @@ class ProfileControllerTest {
@MethodSource
void testSetProfilePaymentAddressCountryNotAllowedExistingPaymentAddress(
@Nullable final byte[] existingPaymentAddressOnProfile, final byte[] requestPaymentAddress, final boolean expectAllowed) throws InvalidInputException {
when(dynamicPaymentsConfiguration.getDisallowedPrefixes())
when(dynamicPaymentsConfiguration.disallowedPrefixes())
.thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3)));
final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));
@@ -81,6 +81,7 @@ import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialRequest;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialRequestContext;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
@@ -183,8 +184,7 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
when(account.getAccountIdentifier()).thenReturn(AUTHENTICATED_ACI);
when(account.getIdentifier(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(AUTHENTICATED_ACI);
when(account.getNumber()).thenReturn(phoneNumber);
when(account.getNumberOptional()).thenReturn(Optional.of(phoneNumber));
when(account.getBadges()).thenReturn(Collections.emptyList());
when(account.hasCapability(DeviceCapability.PROFILES_V2)).thenReturn(true);
@@ -198,7 +198,8 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());
when(dynamicPaymentsConfiguration.disallowedPrefixes()).thenReturn(Collections.emptyList());
when(dynamicPaymentsConfiguration.disallowedAsnRegions()).thenReturn(Collections.emptyList());
when(profilesManager.deleteAvatar(anyString())).thenReturn(CompletableFuture.completedFuture(null));
@@ -206,6 +207,7 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
clock,
accountsManager,
profilesManager,
() -> AsnInfoProvider.EMPTY,
dynamicConfigurationManager,
badgesConfiguration,
policyGenerator,
@@ -510,10 +512,9 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
.setV1Request(V1_REQUEST)
.build();
final String disallowedCountryCode = String.format("+%d", disallowedPhoneNumber.getCountryCode());
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(List.of(disallowedCountryCode));
when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(
disallowedPhoneNumber,
PhoneNumberUtil.PhoneNumberFormat.E164));
when(dynamicPaymentsConfiguration.disallowedPrefixes()).thenReturn(List.of(disallowedCountryCode));
when(account.getNumberOptional()).thenReturn(Optional.of(PhoneNumberUtil.getInstance().format(
disallowedPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164)));
when(profilesManager.getV1(any(), anyString())).thenReturn(Optional.of(profile));
final SetProfileResponse response = authenticatedServiceStub().setProfile(request);
@@ -0,0 +1,145 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.asn.AsnInfo;
import org.whispersystems.textsecuregcm.asn.AsnInfoProvider;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.storage.VersionedProfileV1;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ProfileHelperTest {
private enum PhoneNumberType {
ALLOWED,
FORBIDDEN,
NONE
}
private enum IpAddressType {
ALLOWED,
FORBIDDEN,
UNRECOGNIZED
}
private enum ExistingPaymentAddressType {
CURRENT_PROFILE,
LEGACY_PROFILE,
NONE
}
@ParameterizedTest
@MethodSource
void isPaymentAddressUpdateForbidden(final PhoneNumberType phoneNumberType,
final IpAddressType ipAddressType,
final ExistingPaymentAddressType existingPaymentAddressType,
final boolean expectForbidden) {
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfiguration.getPaymentsConfiguration())
.thenReturn(new DynamicPaymentsConfiguration(List.of("+1"), List.of("US")));
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
final Account account = mock(Account.class);
switch (phoneNumberType) {
case ALLOWED -> when(account.getNumberOptional())
.thenReturn(Optional.of(PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("DE"), PhoneNumberUtil.PhoneNumberFormat.E164)));
case FORBIDDEN -> when(account.getNumberOptional())
.thenReturn(Optional.of(PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164)));
case NONE -> when(account.getNumberOptional()).thenReturn(Optional.empty());
}
final AsnInfoProvider asnInfoProvider = mock(AsnInfoProvider.class);
switch (ipAddressType) {
case ALLOWED -> when(asnInfoProvider.lookup(anyString())).thenReturn(Optional.of(new AsnInfo(123, "DE")));
case FORBIDDEN -> when(asnInfoProvider.lookup(anyString())).thenReturn(Optional.of(new AsnInfo(123, "US")));
case UNRECOGNIZED -> when(asnInfoProvider.lookup(anyString())).thenReturn(Optional.empty());
}
final Optional<VersionedProfile> maybeProfile = switch (existingPaymentAddressType) {
case CURRENT_PROFILE -> Optional.of(new VersionedProfile(TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16)));
case LEGACY_PROFILE, NONE -> Optional.empty();
};
final Optional<VersionedProfileV1> maybeLegacyProfile = switch (existingPaymentAddressType) {
case LEGACY_PROFILE -> Optional.of(new VersionedProfileV1("version",
TestRandomUtil.nextBytes(16),
"avatar",
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16),
TestRandomUtil.nextBytes(16)));
case CURRENT_PROFILE, NONE -> Optional.empty();
};
assertEquals(expectForbidden, ProfileHelper.isPaymentAddressUpdateForbidden(account,
maybeProfile,
maybeLegacyProfile,
"127.0.0.1",
asnInfoProvider,
dynamicConfigurationManager));
}
private static List<Arguments> isPaymentAddressUpdateForbidden() {
return List.of(
Arguments.argumentSet("Permitted phone number",
PhoneNumberType.ALLOWED, IpAddressType.ALLOWED, ExistingPaymentAddressType.NONE, false),
Arguments.argumentSet("Forbidden phone number",
PhoneNumberType.FORBIDDEN, IpAddressType.ALLOWED, ExistingPaymentAddressType.NONE, true),
Arguments.argumentSet("Forbidden phone number, has existing address",
PhoneNumberType.FORBIDDEN, IpAddressType.ALLOWED, ExistingPaymentAddressType.CURRENT_PROFILE, false),
Arguments.argumentSet("Forbidden phone number, has existing address in legacy profile",
PhoneNumberType.FORBIDDEN, IpAddressType.ALLOWED, ExistingPaymentAddressType.LEGACY_PROFILE, false),
Arguments.argumentSet("No phone number, permitted ASN region",
PhoneNumberType.NONE, IpAddressType.ALLOWED, ExistingPaymentAddressType.NONE, false),
Arguments.argumentSet("No phone number, unrecognized ASN region",
PhoneNumberType.NONE, IpAddressType.UNRECOGNIZED, ExistingPaymentAddressType.NONE, false),
Arguments.argumentSet("No phone number, forbidden ASN region",
PhoneNumberType.NONE, IpAddressType.FORBIDDEN, ExistingPaymentAddressType.NONE, true),
Arguments.argumentSet("No phone number, forbidden ASN region, has existing address",
PhoneNumberType.NONE, IpAddressType.FORBIDDEN, ExistingPaymentAddressType.CURRENT_PROFILE, false),
Arguments.argumentSet("No phone number, forbidden ASN region, has existing address in legacy profile",
PhoneNumberType.NONE, IpAddressType.FORBIDDEN, ExistingPaymentAddressType.LEGACY_PROFILE, false)
);
}
}