Compare commits

..

No commits in common. "main" and "v20260624.1.0" have entirely different histories.

56 changed files with 406 additions and 2494 deletions

View File

@ -82,7 +82,6 @@ zkConfig-libsignal-0.42.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcd
genericZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
callingZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
backupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
profileAvatarsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
paymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
paymentsService.fixerApiKey: unset

View File

@ -128,8 +128,6 @@ dynamoDbTables:
expiration: P90D
phoneNumberIdentifiers:
tableName: Example_PhoneNumberIdentifiers
profileAvatars:
tableName: Example_ProfileAvatars
profiles:
tableName: Example_Profiles
profilesV2:
@ -328,9 +326,6 @@ callingZkConfig:
backupsZkConfig:
serverSecret: secret://backupsZkConfig.serverSecret
profileAvatarsZkConfig:
serverSecret: secret://profileAvatarsZkConfig.serverSecret
dynamicConfig:
s3Region: a-region
s3Bucket: a-bucket

View File

@ -235,11 +235,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private GenericZkConfig backupsZkConfig;
@Valid
@NotNull
@JsonProperty
private GenericZkConfig profileAvatarsZkConfig;
@Valid
@NotNull
@JsonProperty
@ -504,10 +499,6 @@ public class WhisperServerConfiguration extends Configuration {
return backupsZkConfig;
}
public GenericZkConfig getProfileAvatarsZkConfig() {
return profileAvatarsZkConfig;
}
public RemoteConfigConfiguration getRemoteConfigConfiguration() {
return remoteConfig;
}

View File

@ -176,7 +176,6 @@ import org.whispersystems.textsecuregcm.grpc.MessageDispatcher;
import org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.MessagesGrpcService;
import org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationsGrpcService;
import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
@ -265,7 +264,6 @@ import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.storage.PersistentTimer;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.ProfileAvatars;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ProfilesV2;
@ -539,8 +537,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
Profiles profilesV1 = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getProfilesV1().getTableName());
ProfilesV2 profiles = new ProfilesV2(dynamoDbClient, dynamoDbAsyncClient, config.getDynamoDbTables().getProfilesV2().getTableName());
ProfileAvatars profileAvatars = new ProfileAvatars(dynamoDbClient,
config.getDynamoDbTables().getProfileAvatars().getTableName(), RemoveExpiredAccountsCommand.MAX_IDLE_DURATION, clock);
S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()
.credentialsProvider(awsCredentialsProvider)
@ -743,7 +739,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
storageServiceExecutor, retryExecutor, config.getSecureStorageServiceConfiguration());
DisconnectionRequestManager disconnectionRequestManager = new DisconnectionRequestManager(pubsubClient,
disconnectionRequestListenerExecutor, retryExecutor);
ProfilesManager profilesManager = new ProfilesManager(profilesV1, profiles, profileAvatars, cacheCluster, retryExecutor, asyncCdnS3Client,
ProfilesManager profilesManager = new ProfilesManager(profilesV1, profiles, cacheCluster, retryExecutor, asyncCdnS3Client,
config.getCdnConfiguration().bucket());
MessagesCache messagesCache = new MessagesCache(messagesCluster, messageDeliveryScheduler,
messageDeletionAsyncExecutor, retryExecutor, clock);
@ -914,7 +910,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());
GenericServerSecretParams callingGenericZkSecretParams = new GenericServerSecretParams(config.getCallingZkConfig().serverSecret().value());
GenericServerSecretParams backupsGenericZkSecretParams = new GenericServerSecretParams(config.getBackupsZkConfig().serverSecret().value());
GenericServerSecretParams profileAvatarsGenericZkSecretParams = new GenericServerSecretParams(config.getProfileAvatarsZkConfig().serverSecret().value());
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
@ -1068,7 +1063,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, profileAvatarsGenericZkSecretParams, profileBadgeConverter, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, messageDispatcher, Clock.systemUTC()),
new BackupsGrpcService(accountsManager, backupAuthManager, backupMetrics),
new DevicesGrpcService(accountsManager),
@ -1094,16 +1089,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AccountsAnonymousGrpcService(accountsManager, rateLimiters),
new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters),
new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()),
new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, profileCdnPolicyGenerator, profileAvatarsGenericZkSecretParams, zkSecretParams, rateLimiters, clock),
new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkSecretParams),
new MessagesAnonymousGrpcService(accountsManager, rateLimiters, messageSender, groupSendTokenUtil, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
new BackupsAnonymousGrpcService(backupManager, backupMetrics, config.getAttachments().maxAttachmentUploadSizeInBytes(), config.getAttachments().maxMessageBackupUploadSizeInBytes()),
new CredentialsAnonymousGrpcService(accountsManager, ExternalServiceDefinitions.SVR.generatorFactory().apply(config, Clock.systemUTC())),
new SubscriptionsGrpcService(clock, config.getSubscription(), config.getOneTimeDonations(), subscriptionManager,
donationPermitsManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager),
new OneTimeDonationsGrpcService(config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, oneTimeDonationsManager, issuedReceiptsManager,
zkReceiptOperations, clock, rateLimiters, donationPermitsManager))
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@ -1375,7 +1367,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
}
}
static void main(String[] args) throws Exception {
public static void main(String[] args) throws Exception {
new WhisperServerService().run(args);
}
}

View File

@ -64,7 +64,6 @@ public class DynamoDbTables {
private final TableWithExpiration messages;
private final TableWithExpiration onetimeDonations;
private final Table phoneNumberIdentifiers;
private final Table profileAvatars;
private final Table profiles;
private final Table profilesV2;
private final Table pushChallenge;
@ -95,7 +94,6 @@ public class DynamoDbTables {
@JsonProperty("messages") final TableWithExpiration messages,
@JsonProperty("onetimeDonations") final TableWithExpiration onetimeDonations,
@JsonProperty("phoneNumberIdentifiers") final Table phoneNumberIdentifiers,
@JsonProperty("profileAvatars") final Table profileAvatars,
@JsonProperty("profiles") final Table profiles,
@JsonProperty("profilesV2") final Table profilesV2,
@JsonProperty("pushChallenge") final Table pushChallenge,
@ -125,7 +123,6 @@ public class DynamoDbTables {
this.messages = messages;
this.onetimeDonations = onetimeDonations;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.profileAvatars = profileAvatars;
this.profiles = profiles;
this.profilesV2 = profilesV2;
this.pushChallenge = pushChallenge;
@ -241,12 +238,6 @@ public class DynamoDbTables {
return phoneNumberIdentifiers;
}
@NotNull
@Valid
public Table getProfileAvatars() {
return profileAvatars;
}
@NotNull
@Valid
public Table getProfilesV1() {

View File

@ -33,8 +33,6 @@ import org.glassfish.jersey.server.ManagedAsync;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -103,23 +101,15 @@ public class KeyTransparencyController {
.build()
));
final SearchResponseV2 searchResponse = keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize());
if (searchResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!searchResponse.hasSearchResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing search response"));
}
return new KeyTransparencySearchResponse(searchResponse.getSearchResponse().toByteArray());
return new KeyTransparencySearchResponse(
keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize())
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
@ -173,22 +163,13 @@ public class KeyTransparencyController {
.setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))
.build());
final MonitorResponseV2 monitorResponse = keyTransparencyServiceClient.monitor(
return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(
aciMonitorRequest,
usernameHashMonitorRequest,
e164MonitorRequest,
request.lastNonDistinguishedTreeHeadSize(),
request.lastDistinguishedTreeHeadSize());
if (monitorResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!monitorResponse.hasMonitorResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing monitor response"));
}
return new KeyTransparencyMonitorResponse(monitorResponse.getMonitorResponse().toByteArray());
request.lastDistinguishedTreeHeadSize())
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}

View File

@ -39,7 +39,6 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -56,7 +55,6 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationUtil;
import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -74,9 +72,11 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
/**
* Endpoints for making one-time donation payments (boost and gift)
* <p>
@ -90,6 +90,8 @@ public class OneTimeDonationController {
private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class);
private static final String EURO_CURRENCY_CODE = "EUR";
private final Clock clock;
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
@ -101,15 +103,15 @@ public class OneTimeDonationController {
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationController(
final Clock clock,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final ServerZkReceiptOperations zkReceiptOperations,
final IssuedReceiptsManager issuedReceiptsManager,
final OneTimeDonationsManager oneTimeDonationsManager,
final DonationPermitsManager donationPermitsManager) {
Clock clock,
OneTimeDonationConfiguration oneTimeDonationConfiguration,
StripeManager stripeManager,
BraintreeManager braintreeManager,
PayPalDonationsTranslator payPalDonationsTranslator,
ServerZkReceiptOperations zkReceiptOperations,
IssuedReceiptsManager issuedReceiptsManager,
OneTimeDonationsManager oneTimeDonationsManager,
DonationPermitsManager donationPermitsManager) {
this.clock = Objects.requireNonNull(clock);
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
@ -149,10 +151,10 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a Stripe payment intent", description = """
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
@ApiResponse(responseCode = "200", description = "Payment Intent created", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class)))
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
@ApiResponse(responseCode = "400", description = """
@ -169,12 +171,13 @@ public class OneTimeDonationController {
@ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
@RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION)
public CompletableFuture<Response> createBoostPaymentIntent(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional<DonationPermitHeader> donationPermitHeader,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@NotNull @Valid final CreateBoostRequest request,
@NotNull @Valid CreateBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -186,7 +189,7 @@ public class OneTimeDonationController {
permitHeader -> {
try {
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
} catch (final VerificationFailedException e) {
} catch (VerificationFailedException e) {
return false;
}
})
@ -203,34 +206,47 @@ public class OneTimeDonationController {
}
/**
* Validates that the request level is valid, the currency is supported by the {@code manager} and
* {@code request.paymentMethod}, and that the amount meets minimum and maximum constraints.
* Validates that the request level is valid, the currency is supported by the {@code manager} and {@code request.paymentMethod},
* and that the amount meets minimum and maximum constraints.
*
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/
private void validateRequestCurrencyAmount(final CreateBoostRequest request, final BigDecimal amount,
final CustomerAwareSubscriptionPaymentProcessor manager) {
final Map<String, String> errorBody = switch (OneTimeDonationUtil.validateOneTimeDonationRequest(request.currency,
amount, request.level, request.paymentMethod, oneTimeDonationConfiguration, manager)) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
Map.of("error", "invalid_level");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
Map.of("error", "unsupported_currency");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum(final BigDecimal min) ->
Map.of("error", "amount_below_currency_minimum",
"minimum", min.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(final BigDecimal max) ->
Map.of("error", "amount_above_sepa_limit",
"maximum", max.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> Collections.emptyMap();
};
if (!errorBody.isEmpty()) {
if (!(request.level == oneTimeDonationConfiguration.gift().level()
|| request.level == oneTimeDonationConfiguration.boost().level())) {
throw new BadRequestException(
Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build());
Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).build());
}
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
.contains(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "unsupported_currency")).build());
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
request.currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_below_currency_minimum",
"minimum", minCurrencyAmountMajorUnits.toString())).build());
}
if (request.paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_above_sepa_limit",
"maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build());
}
}
public static class CreatePayPalBoostRequest extends CreateBoostRequest {
@ -252,9 +268,9 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreatePayPalBoostRequest request,
@Context final ContainerRequestContext containerRequestContext) {
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreatePayPalBoostRequest request,
@Context ContainerRequestContext containerRequestContext) {
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
@ -265,11 +281,21 @@ public class OneTimeDonationController {
.thenCompose(_ -> {
final List<Locale> acceptableLanguages =
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLanguages);
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = acceptableLanguages.stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages,
PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
localizedLineItem.locale().toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItem.itemName());
locale.toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItemName);
})
.thenApply(approvalDetails -> Response.ok(
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
@ -296,8 +322,8 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final ConfirmPayPalBoostRequest request,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid ConfirmPayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -340,7 +366,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -348,56 +374,68 @@ public class OneTimeDonationController {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
}
final CompletableFuture<Optional<PaymentDetails>> paymentDetailsFut = switch (request.processor) {
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
};
return paymentDetailsFut.thenApply(maybePaymentDetails -> {
if (maybePaymentDetails.isEmpty()) {
return paymentDetailsFut.thenApply(paymentDetails -> {
if (paymentDetails == null) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
} else if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return Response.noContent().build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
} else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
.entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());
}
// The payment was successful, try to issue the receipt credential
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (OneTimeDonationUtil.InvalidLevelException _) {
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (NumberFormatException e) {
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
Duration levelExpiration;
if (oneTimeDonationConfiguration.boost().level() == level) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (oneTimeDonationConfiguration.gift().level() == level) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
logger.error("level ({}) returned from payment intent that is unknown to the server", level);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
final ReceiptCredentialRequest receiptCredentialRequest;
ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
} catch (final InvalidInputException e) {
} catch (InvalidInputException e) {
throw new BadRequestException("invalid receipt credential request", e);
}
final long finalLevel = level;
try {
issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,
receiptCredentialRequest, clock.instant());
} catch (WriteConflictException _) {
} catch (final WriteConflictException _) {
throw new WebApplicationException(Response.Status.CONFLICT);
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelDetails.levelExpiration())
.plus(levelExpiration)
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
receiptCredentialRequest, expiration.getEpochSecond(), finalLevel);
} catch (final VerificationFailedException e) {
throw new BadRequestException("receipt credential request failed verification", e);
}

View File

@ -8,7 +8,6 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildCurrencyConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildDonationLevelsConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
@ -217,7 +216,7 @@ public class SubscriptionController {
public Response updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit. Not required if the subscriber already exists.")
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@ -319,7 +318,10 @@ public class SubscriptionController {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext));
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@ -43,8 +44,6 @@ import org.signal.chat.account.UsernameNotAvailable;
import org.signal.chat.common.AccountIdentifiers;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ZkCredentialPublicKey;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
@ -272,14 +271,9 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account authenticatedAccount = getAuthenticatedAccount();
final ZkCredentialPublicKey zkCredentialKey;
try {
zkCredentialKey = new ZkCredentialPublicKey(request.getPublicKey().toByteArray());
} catch (InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid public key bytes");
}
final byte[] zkCredentialKey = request.getPublicKey().toByteArray();
if (authenticatedAccount.getZkCredentialKey().map(zkCredentialKey::equals).orElse(false)) {
if (Arrays.equals(authenticatedAccount.getZkCredentialKey(), zkCredentialKey)) {
return SetZkCredentialKeyResponse.newBuilder()
.setRotationId(Objects.requireNonNull(authenticatedAccount.getZkCredentialKeyRotationId()))
.build();

View File

@ -15,10 +15,8 @@ import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.whispersystems.textsecuregcm.controllers.AccountController;
@ -41,51 +39,102 @@ public class KeyTransparencyGrpcService extends
}
@Override
public SearchResponseV2 searchV2(final SearchRequest request) throws RateLimitExceededException {
public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.search(validateSearchRequest(request));
}
@Override
public MonitorResponseV2 monitorV2(final MonitorRequest request) throws RateLimitExceededException {
public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.monitor(validateMonitorRequest(request));
}
@Override
public DistinguishedResponse distinguishedV2(final DistinguishedRequest request) throws RateLimitExceededException {
public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
// A client's very first distinguished request will not have a "last" parameter
if (request.hasLast() && request.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
return client.distinguished(request);
}
private SearchRequest validateSearchRequest(final SearchRequest request) {
validateAci(request.getAci().toByteArray());
if (request.hasE164SearchRequest()) {
final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();
if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {
throw GrpcExceptions.fieldViolation("e164_search_request", "Unidentified access key and E164 must be provided together or not at all");
throw Status.INVALID_ARGUMENT.withDescription("Unidentified access key and E164 must be provided together or not at all").asRuntimeException();
}
}
return request;
}
private void validateAci(final byte[] aci) {
try {
AciServiceIdentifier.fromBytes(aci);
} catch (IllegalArgumentException e) {
throw GrpcExceptions.fieldViolation("aci", "Invalid ACI");
if (!request.getConsistency().hasDistinguished()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished tree head size").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private MonitorRequest validateMonitorRequest(final MonitorRequest request) {
validateAci(request.getAci().getAci().toByteArray());
final AciMonitorRequest aciMonitorRequest = request.getAci();
if (!request.getConsistency().hasLast()) {
throw GrpcExceptions.fieldViolation("consistency_last", "Must provide distinguished and last tree head sizes");
try {
AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());
} catch (IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid ACI").asRuntimeException();
}
if (aciMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Aci entry position must be positive").asRuntimeException();
}
if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Aci commitment index must be 32 bytes").asRuntimeException();
}
if (request.hasUsernameHash()) {
final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();
if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash cannot be empty").asRuntimeException();
}
if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid username hash length").asRuntimeException();
}
if (usernameHashMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash entry position must be positive").asRuntimeException();
}
if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash commitment index must be 32 bytes").asRuntimeException();
}
}
if (request.hasE164()) {
final E164MonitorRequest e164MonitorRequest = request.getE164();
if (e164MonitorRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("E164 cannot be empty").asRuntimeException();
}
if (e164MonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("E164 entry position must be positive").asRuntimeException();
}
if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("E164 commitment index must be 32 bytes").asRuntimeException();
}
}
if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished and last tree head sizes").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private static void validateConsistencyParameters(final ConsistencyParameters consistency) {
if (consistency.getDistinguished() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Distinguished tree head size must be positive").asRuntimeException();
}
if (consistency.hasLast() && consistency.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
}
}

View File

@ -1,130 +0,0 @@
package org.whispersystems.textsecuregcm.grpc;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
public class OneTimeDonationUtil {
private static final String EURO_CURRENCY_CODE = "EUR";
private static final Logger LOGGER = LoggerFactory.getLogger(OneTimeDonationUtil.class);
/// Thrown if a one time donation level cannot be parsed or if it is not found in configuration
public static class InvalidLevelException extends Exception {
public InvalidLevelException(final String message) {
super(message);
}
}
public record LocalizedPayPalDonationLineItem(Locale locale, String itemName){}
public record DonationLevelDetails(long level, Duration levelExpiration){}
public sealed interface OneTimeDonationRequestValidationResult permits OneTimeDonationRequestValidationResult.Success,
OneTimeDonationRequestValidationResult.UnsupportedCurrency,
OneTimeDonationRequestValidationResult.UnsupportedLevel,
OneTimeDonationRequestValidationResult.AmountBelowMinimum,
OneTimeDonationRequestValidationResult.AmountAboveSepaLimit {
record Success() implements OneTimeDonationRequestValidationResult {}
record UnsupportedCurrency() implements OneTimeDonationRequestValidationResult {}
record UnsupportedLevel() implements OneTimeDonationRequestValidationResult {}
record AmountBelowMinimum(BigDecimal minimum) implements OneTimeDonationRequestValidationResult {}
record AmountAboveSepaLimit(BigDecimal maximum) implements OneTimeDonationRequestValidationResult {}
}
public static OneTimeDonationRequestValidationResult validateOneTimeDonationRequest(
final String currency,
final BigDecimal amount,
final long level,
final PaymentMethod paymentMethod,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final CustomerAwareSubscriptionPaymentProcessor manager
) {
if (!(level == oneTimeDonationConfiguration.gift().level()
|| level == oneTimeDonationConfiguration.boost().level())) {
return new OneTimeDonationRequestValidationResult.UnsupportedLevel();
}
if (!manager.getSupportedCurrenciesForPaymentMethod(paymentMethod)
.contains(currency.toLowerCase(Locale.ROOT))) {
return new OneTimeDonationRequestValidationResult.UnsupportedCurrency();
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
return new OneTimeDonationRequestValidationResult.AmountBelowMinimum(minCurrencyAmountMajorUnits);
}
if (paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
return new OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(
oneTimeDonationConfiguration.sepaMaximumEuros());
}
return new OneTimeDonationRequestValidationResult.Success();
}
public static LocalizedPayPalDonationLineItem localizePayPalDonationLineItem(
final PayPalDonationsTranslator payPalDonationsTranslator, final List<Locale> acceptableLocales) {
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales,
org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName);
}
public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails,
final OneTimeDonationConfiguration oneTimeDonationConfiguration)
throws InvalidLevelException {
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
final String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (final NumberFormatException e) {
LOGGER.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new InvalidLevelException("failed to parse level metadata");
}
}
final Duration levelExpiration;
if (level == oneTimeDonationConfiguration.boost().level()) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (level == oneTimeDonationConfiguration.gift().level()) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
LOGGER.error("level ({}) returned from payment intent that is unknown to the server", level);
throw new InvalidLevelException("unrecognized level");
}
return new DonationLevelDetails(level, levelExpiration);
}
}

View File

@ -1,326 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.toChargeFailure;
import com.google.protobuf.ByteString;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.chat.errors.NotFound;
import org.signal.chat.one_time_donations.AmountAboveSepaLimitError;
import org.signal.chat.one_time_donations.AmountBelowMinimumError;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
import org.signal.chat.one_time_donations.CreateBoostRequest;
import org.signal.chat.one_time_donations.CreateBoostResponse;
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
import org.signal.chat.one_time_donations.SimpleOneTimeDonationsGrpc;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
public class OneTimeDonationsGrpcService extends SimpleOneTimeDonationsGrpc.OneTimeDonationsImplBase {
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final PayPalDonationsTranslator payPalDonationsTranslator;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final IssuedReceiptsManager issuedReceiptsManager;
private final ServerZkReceiptOperations zkReceiptOperations;
private final Clock clock;
private final RateLimiters rateLimiters;
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationsGrpcService(
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final OneTimeDonationsManager oneTimeDonationsManager,
final IssuedReceiptsManager issuedReceiptsManager,
final ServerZkReceiptOperations zkReceiptOperations,
final Clock clock,
final RateLimiters rateLimiters,
final DonationPermitsManager donationPermitsManager) {
this.oneTimeDonationConfiguration = oneTimeDonationConfiguration;
this.stripeManager = stripeManager;
this.braintreeManager = braintreeManager;
this.payPalDonationsTranslator = payPalDonationsTranslator;
this.oneTimeDonationsManager = oneTimeDonationsManager;
this.issuedReceiptsManager = issuedReceiptsManager;
this.zkReceiptOperations = zkReceiptOperations;
this.clock = clock;
this.rateLimiters = rateLimiters;
this.donationPermitsManager = donationPermitsManager;
}
@Override
public CreateBoostResponse createBoost(final CreateBoostRequest request) throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
try {
final DonationPermit donationPermit = new DonationPermit(request.getDonationPermit().toByteArray());
if (!donationPermitsManager.spend(donationPermit)) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
} catch (InvalidInputException | VerificationFailedException _) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod =
switch (request.getPaymentMethod()) {
case PAYMENT_METHOD_CARD -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD;
case PAYMENT_METHOD_SEPA_DEBIT -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT;
case PAYMENT_METHOD_IDEAL -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.IDEAL;
default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method");
};
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
paymentMethod,
oneTimeDonationConfiguration,
stripeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreateBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit r ->
CreateBoostResponse.newBuilder()
.setAmountAboveSepaLimit(AmountAboveSepaLimitError.newBuilder()
.setMaximum(r.maximum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final com.stripe.model.PaymentIntent paymentIntent = stripeManager.createPaymentIntent(
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
yield CreateBoostResponse.newBuilder().setClientSecret(paymentIntent.getClientSecret()).build();
}
};
}
@Override
public CreatePayPalBoostResponse createPayPalBoost(final CreatePayPalBoostRequest request) {
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreatePayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final List<Locale> acceptableLocales = RequestAttributesUtil.getAvailableAcceptedLocales();
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLocales);
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
braintreeManager.createOneTimePayment(
request.getCurrency().toUpperCase(Locale.ROOT), request.getAmount(),
localizedLineItem.locale().toLanguageTag(), request.getReturnUrl(), request.getCancelUrl(),
localizedLineItem.itemName()).join();
yield CreatePayPalBoostResponse.newBuilder()
.setResult(CreatePayPalBoostResponse.CreatePayPalBoostResult.newBuilder()
.setApprovalUrl(approvalDetails.approvalUrl())
.setPaymentId(approvalDetails.paymentId()).build()).build();
}
};
}
@Override
public ConfirmPayPalBoostResponse confirmPayPalBoost(final ConfirmPayPalBoostRequest request)
throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
ConfirmPayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final BraintreeManager.PayPalChargeSuccessDetails chargeSuccessDetails =
braintreeManager.captureOneTimePayment(
request.getPayerId(), request.getPaymentId(), request.getPaymentToken(),
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), clock.instant());
yield ConfirmPayPalBoostResponse.newBuilder()
.setResult(ConfirmPayPalBoostResponse.ConfirmPayPalBoostResult.newBuilder()
.setPaymentId(chargeSuccessDetails.paymentId()).build()).build();
}
};
}
@Override
public CreateBoostReceiptCredentialsResponse createBoostReceiptCredentials(
final CreateBoostReceiptCredentialsRequest request) {
final PaymentProvider processor;
final Optional<PaymentDetails> maybePaymentDetails;
switch (request.getProcessor()) {
case PAYMENT_PROVIDER_STRIPE -> {
processor = PaymentProvider.STRIPE;
maybePaymentDetails = stripeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
case PAYMENT_PROVIDER_BRAINTREE -> {
processor = PaymentProvider.BRAINTREE;
maybePaymentDetails = braintreeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
default -> throw GrpcExceptions.fieldViolation("processor", "Unsupported payment processor");
}
if (maybePaymentDetails.isEmpty()) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentNotFound(NotFound.getDefaultInstance()).build();
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentStillProcessing(FailedPrecondition.getDefaultInstance()).build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
final PaymentRequired.Builder paymentRequiredBuilder = PaymentRequired.newBuilder();
if (paymentDetails.chargeFailure() != null) {
paymentRequiredBuilder.setChargeFailure(toChargeFailure(processor, paymentDetails.chargeFailure()));
}
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(paymentRequiredBuilder).build();
}
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (final OneTimeDonationUtil.InvalidLevelException e) {
throw GrpcExceptions.unavailable(e.getMessage());
}
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(
request.getReceiptCredentialRequest().toByteArray());
} catch (final InvalidInputException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request", "invalid receipt credential request");
}
try {
issuedReceiptsManager.recordIssuance(
paymentDetails.id(), processor, receiptCredentialRequest, clock.instant());
} catch (final WriteConflictException e) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setReceiptAlreadyIssued(FailedPrecondition.getDefaultInstance()).build();
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelDetails.levelExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
} catch (final VerificationFailedException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request",
"receipt credential request failed verification");
}
Metrics.counter(SubscriptionsGrpcService.RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(SubscriptionsGrpcService.PROCESSOR_TAG_NAME, processor.toString()),
Tag.of(SubscriptionsGrpcService.TYPE_TAG_NAME, "boost"),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setResult(CreateBoostReceiptCredentialsResponse.CreateBoostReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(receiptCredentialResponse.serialize()))
.build())
.build();
}
}

View File

@ -5,21 +5,12 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.Empty;
import java.time.Clock;
import java.util.Base64;
import java.util.Optional;
import com.google.common.annotations.VisibleForTesting;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.chat.errors.NotFound;
import org.signal.chat.profile.CredentialType;
import org.signal.chat.profile.DeleteAvatarRequest;
import org.signal.chat.profile.DeleteAvatarResponse;
import org.signal.chat.profile.ExtendAvatarTTLRequest;
import org.signal.chat.profile.ExtendAvatarTTLResponse;
import org.signal.chat.profile.GetAvatarUploadFormRequest;
import org.signal.chat.profile.GetAvatarUploadFormResponse;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousResponse;
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
@ -27,75 +18,44 @@ import org.signal.chat.profile.GetUnversionedProfileAnonymousResponse;
import org.signal.chat.profile.GetVersionedProfileAnonymousRequest;
import org.signal.chat.profile.GetVersionedProfileAnonymousResponse;
import org.signal.chat.profile.SimpleProfileAnonymousGrpc;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialPresentation;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
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;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.ProfileAnonymousImplBase {
private final AccountsManager accountsManager;
private final ProfilesManager profilesManager;
private final ProfileBadgeConverter profileBadgeConverter;
private final ServerZkProfileOperations zkProfileOperations;
private final GenericServerSecretParams genericServerSecretParams;
private final GroupSendTokenUtil groupSendTokenUtil;
private final PostPolicyGenerator policyGenerator;
private final RateLimiters rateLimiters;
private final Clock clock;
public ProfileAnonymousGrpcService(
final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final ProfileBadgeConverter profileBadgeConverter,
final PostPolicyGenerator policyGenerator,
final GenericServerSecretParams genericServerSecretParams,
final ServerSecretParams serverSecretParams,
final RateLimiters rateLimiters,
final Clock clock) {
final ServerSecretParams serverSecretParams) {
this(accountsManager,
profilesManager,
profileBadgeConverter,
policyGenerator,
genericServerSecretParams,
rateLimiters,
clock,
new ServerZkProfileOperations(serverSecretParams),
new GroupSendTokenUtil(serverSecretParams, clock));
new GroupSendTokenUtil(serverSecretParams, Clock.systemUTC()));
}
@VisibleForTesting
ProfileAnonymousGrpcService(final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final ProfileBadgeConverter profileBadgeConverter,
final PostPolicyGenerator policyGenerator,
final GenericServerSecretParams genericServerSecretParams,
final RateLimiters rateLimiters,
final Clock clock,
final ServerZkProfileOperations zkProfileOperations,
final GroupSendTokenUtil groupSendTokenUtil) {
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.profileBadgeConverter = profileBadgeConverter;
this.policyGenerator = policyGenerator;
this.genericServerSecretParams = genericServerSecretParams;
this.rateLimiters = rateLimiters;
this.clock = clock;
this.zkProfileOperations = zkProfileOperations;
this.groupSendTokenUtil = groupSendTokenUtil;
}
@ -192,92 +152,4 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
return accountsManager.getByServiceIdentifier(targetIdentifier)
.filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey));
}
@Override
public GetAvatarUploadFormResponse getAvatarUploadForm(final GetAvatarUploadFormRequest request) throws RateLimitExceededException {
final AvatarUploadCredentialPresentation presentation;
try {
presentation = new AvatarUploadCredentialPresentation(
request.getAvatarCredentialsPresentation().toByteArray());
presentation.verify(clock.instant(), this.genericServerSecretParams);
} catch (InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid credential presentation");
} catch (VerificationFailedException _) {
return GetAvatarUploadFormResponse.newBuilder()
.setInvalidCredentialsPresentation(FailedZkAuthentication.getDefaultInstance())
.build();
}
final byte[] identity = presentation.getCommitment();
rateLimiters.getProfileAvatarBytesLimiter().validate(Base64.getEncoder().encodeToString(identity), request.getUploadLength());
final String avatar = ProfileHelper.generateAvatarObjectName();
profilesManager.setAvatarForIdentity(identity, avatar);
return GetAvatarUploadFormResponse.newBuilder()
.setAvatarUploadForm(ProfileGrpcHelper.generateAvatarUploadForm(avatar, request.getUploadLength(), policyGenerator, clock))
.build();
}
@Override
public ExtendAvatarTTLResponse extendAvatarTTL(final ExtendAvatarTTLRequest request) {
final AvatarUploadCredentialPresentation presentation;
try {
presentation = new AvatarUploadCredentialPresentation(
request.getAvatarCredentialsPresentation().toByteArray());
} catch (InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid credential presentation");
}
try {
presentation.verify(clock.instant(), this.genericServerSecretParams);
} catch (VerificationFailedException _) {
return ExtendAvatarTTLResponse.newBuilder()
.setInvalidCredentialsPresentation(FailedZkAuthentication.getDefaultInstance())
.build();
}
final byte[] identity = presentation.getCommitment();
return profilesManager.extendAvatarTtlForIdentity(identity)
.map(extendedPath -> ExtendAvatarTTLResponse.newBuilder()
.setPath(extendedPath)
.build())
.orElseGet(() -> ExtendAvatarTTLResponse.newBuilder()
.setNotFound(NotFound.getDefaultInstance())
.build());
}
@Override
public DeleteAvatarResponse deleteAvatar(final DeleteAvatarRequest request) {
final AvatarUploadCredentialPresentation presentation;
try {
presentation = new AvatarUploadCredentialPresentation(
request.getAvatarCredentialsPresentation().toByteArray());
} catch (InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid credential presentation");
}
try {
presentation.verify(clock.instant(), this.genericServerSecretParams);
} catch (VerificationFailedException _) {
return DeleteAvatarResponse.newBuilder()
.setInvalidCredentialsPresentation(FailedZkAuthentication.getDefaultInstance())
.build();
}
final byte[] identity = presentation.getCommitment();
profilesManager.deleteAvatarForIdentity(identity);
return DeleteAvatarResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
}
}

View File

@ -9,7 +9,6 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.ByteString;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
@ -18,7 +17,6 @@ import java.util.Optional;
import java.util.UUID;
import org.signal.chat.common.Badge;
import org.signal.chat.common.BadgeSvg;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.profile.DataEtag;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResult;
import org.signal.chat.profile.GetUnversionedProfileResult;
@ -34,7 +32,6 @@ import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
@ -43,12 +40,6 @@ import org.whispersystems.textsecuregcm.storage.VersionedProfileV1;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
public class ProfileGrpcHelper {
private static final S3UploadForm PROTOTYPE_AVATAR_UPLOAD_FORM = S3UploadForm.newBuilder()
.setAcl(PostPolicyGenerator.ACL)
.setAlgorithm(PostPolicyGenerator.ALGORITHM)
.build();
static Optional<GetVersionedProfileResult> getVersionedProfile(final Account account,
final ProfilesManager profilesManager,
final byte[] requestVersion,
@ -226,20 +217,6 @@ public class ProfileGrpcHelper {
});
}
public static S3UploadForm generateAvatarUploadForm(final String objectName, final int uploadLength,
final PostPolicyGenerator policyGenerator, final Clock clock) {
final PostPolicyGenerator.SignedPostPolicy policy =
policyGenerator.createFor(objectName, uploadLength, clock.instant());
return PROTOTYPE_AVATAR_UPLOAD_FORM.toBuilder()
.setKey(objectName)
.setCredential(policy.credential())
.setDate(policy.formattedTimestamp())
.setPolicy(policy.encodedPolicy())
.setSignature(policy.signature())
.build();
}
/**
* Computes the SHA-256 hash of the given data.
* <p>

View File

@ -5,14 +5,11 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
@ -20,8 +17,6 @@ import java.util.stream.Collectors;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.NotFound;
import org.signal.chat.profile.GetAvatarCredentialsRequest;
import org.signal.chat.profile.GetAvatarCredentialsResponse;
import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileRequest;
@ -33,14 +28,6 @@ import org.signal.chat.profile.SetProfileResponse;
import org.signal.chat.profile.SetProfileResult;
import org.signal.chat.profile.SetProfileV1Request.AvatarChange;
import org.signal.chat.profile.SimpleProfileGrpc;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
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.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
@ -65,18 +52,20 @@ 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 DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PostPolicyGenerator policyGenerator;
private final GenericServerSecretParams genericServerSecretParams;
private final ProfileBadgeConverter profileBadgeConverter;
private final RateLimiters rateLimiters;
private static final S3UploadForm PROTOTYPE_AVATAR_UPLOAD_FORM = S3UploadForm.newBuilder()
.setAcl(PostPolicyGenerator.ACL)
.setAlgorithm(PostPolicyGenerator.ALGORITHM)
.build();
private record AvatarData(Optional<String> currentAvatar,
Optional<String> finalAvatar,
Optional<S3UploadForm> uploadAttributes) {}
@ -88,7 +77,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final BadgesConfiguration badgesConfiguration,
final PostPolicyGenerator policyGenerator,
final GenericServerSecretParams genericServerSecretParams,
final ProfileBadgeConverter profileBadgeConverter,
final RateLimiters rateLimiters) {
this.clock = clock;
@ -98,7 +86,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.policyGenerator = policyGenerator;
this.genericServerSecretParams = genericServerSecretParams;
this.profileBadgeConverter = profileBadgeConverter;
this.rateLimiters = rateLimiters;
}
@ -149,7 +136,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
case AVATAR_CHANGE_UPDATE -> {
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
Optional.of(ProfileGrpcHelper.generateAvatarUploadForm(updateAvatarObjectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, policyGenerator, clock)));
Optional.of(generateAvatarUploadForm(updateAvatarObjectName)));
}
};
@ -218,39 +205,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
.build());
}
@Override
public GetAvatarCredentialsResponse getAvatarCredentials(final GetAvatarCredentialsRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("account not found"));
if (account.getZkCredentialKey().isEmpty()) {
return GetAvatarCredentialsResponse.newBuilder()
.setMissingZkCredentialKey(FailedPrecondition.newBuilder().setDescription("account requires ZK credential key"))
.build();
}
try {
final AvatarUploadCredentialRequest credentialRequest = new AvatarUploadCredentialRequest(
request.getAvatarCredentialsRequest().toByteArray());
final AvatarUploadCredentialResponse credentialResponse = credentialRequest.issueCredential(
new ServiceId.Aci(account.getIdentifier(IdentityType.ACI)),
account.getZkCredentialKey().get(),
Objects.requireNonNull(account.getZkCredentialKeyRotationId()),
clock.instant().truncatedTo(ChronoUnit.DAYS),
this.genericServerSecretParams);
return GetAvatarCredentialsResponse.newBuilder()
.setAvatarCredentials(ByteString.copyFrom(credentialResponse.serialize()))
.build();
} catch (InvalidInputException | VerificationFailedException _) {
throw GrpcExceptions.invalidArguments("invalid credential request");
}
}
@Override
public GetUnversionedProfileResponse getUnversionedProfile(final GetUnversionedProfileRequest request) throws RateLimitExceededException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
@ -309,4 +263,17 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
throw GrpcExceptions.invalidArguments("Request must include commitment during migration");
}
}
private S3UploadForm generateAvatarUploadForm(final String objectName) {
final PostPolicyGenerator.SignedPostPolicy policy =
policyGenerator.createFor(objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, clock.instant());
return PROTOTYPE_AVATAR_UPLOAD_FORM.toBuilder()
.setKey(objectName)
.setCredential(policy.credential())
.setDate(policy.formattedTimestamp())
.setPolicy(policy.encodedPolicy())
.setSignature(policy.signature())
.build();
}
}

View File

@ -1,7 +1,6 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
@ -12,9 +11,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.FailedZkAuthentication;
@ -34,7 +30,6 @@ import org.signal.chat.subscriptions.GetReceiptCredentialsResponse;
import org.signal.chat.subscriptions.GetSubscriptionInformationRequest;
import org.signal.chat.subscriptions.GetSubscriptionInformationResponse;
import org.signal.chat.subscriptions.PaymentMethod;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodRequest;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodResponse;
import org.signal.chat.subscriptions.SetIapSubscriptionRequest;
@ -55,8 +50,6 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
@ -66,9 +59,11 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException;
@ -102,11 +97,6 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
private final BankMandateTranslator bankMandateTranslator;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionsGrpcService.class, "receiptIssued");
static final String PROCESSOR_TAG_NAME = "processor";
static final String TYPE_TAG_NAME = "type";
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
public SubscriptionsGrpcService(final Clock clock, final SubscriptionConfiguration subscriptionConfiguration,
final OneTimeDonationConfiguration oneTimeDonationConfiguration, final SubscriptionManager subscriptionManager,
final DonationPermitsManager donationPermitsManager, final StripeManager stripeManager,
@ -146,7 +136,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _) {
} catch (final VerificationFailedException _ ) {
return UpdateSubscriberResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit failed verification")
@ -208,7 +198,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _) {
} catch (final VerificationFailedException _ ) {
return CreatePaymentMethodResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.getDefaultInstance())
.build();
@ -237,7 +227,8 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
public CreatePayPalPaymentMethodResponse createPayPalPaymentMethod(final CreatePayPalPaymentMethodRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final Locale locale = getPayPalLocale(RequestAttributesUtil.getAvailableAcceptedLocales());
final Locale locale = RequestAttributesUtil.getAcceptableLanguages().stream().filter(r -> !"*".equals(r.getRange()))
.findFirst().map(r -> Locale.forLanguageTag(r.getRange())).orElse(Locale.US);
try {
final BraintreeManager.PayPalBillingAgreementApprovalDetails details = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials, braintreeManager, getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)),
@ -372,7 +363,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
.build();
} catch (final SubscriptionProcessorException e) {
return SetSubscriptionLevelResponse.newBuilder()
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
} catch (final SubscriptionPaymentRequiresActionException e) {
return SetSubscriptionLevelResponse.newBuilder().setPaymentRequiresAction(FailedPrecondition.newBuilder().build())
.build();
@ -452,7 +443,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
subscription.setBillingCycleAnchor(info.billingCycleAnchor().getEpochSecond());
}
if (info.chargeFailure() != null) {
subscription.setChargeFailure(SubscriptionsUtil.toChargeFailure(info.paymentProvider(), info.chargeFailure()));
subscription.setChargeFailure(toChargeFailure(info.paymentProvider(), info.chargeFailure()));
}
return GetSubscriptionInformationResponse.newBuilder().setSuccess(subscription.build()).build();
@ -467,15 +458,6 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()),
Tag.of(TYPE_TAG_NAME, "subscription"),
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
subscriptionConfiguration.getSubscriptionLevel(result.receiptItem().level()).type().name()
.toLowerCase(Locale.ROOT)),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return GetReceiptCredentialsResponse.newBuilder().setSuccess(
GetReceiptCredentialsResponse.GetReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(result.receiptCredentialResponse().serialize()))
@ -485,11 +467,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
.build();
} catch (final SubscriptionChargeFailurePaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder().setPaymentRequired(
PaymentRequired.newBuilder()
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
GetReceiptCredentialsResponse.PaymentRequired.newBuilder()
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
} catch (final SubscriptionPaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(PaymentRequired.newBuilder().build()).build();
.setPaymentRequired(GetReceiptCredentialsResponse.PaymentRequired.newBuilder().build()).build();
} catch (final SubscriptionInvalidArgumentsException e) {
throw GrpcExceptions.invalidArguments(e.errorDetail().orElse(""));
} catch (final SubscriptionReceiptAlreadyRedeemedException e) {
@ -607,4 +589,20 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
return GetBankMandateResponse.newBuilder().setMandate(mandate).build();
}
private static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
final ChargeFailure chargeFailure) {
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
if (chargeFailure.outcomeNetworkStatus() != null) {
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
}
if (chargeFailure.outcomeReason() != null) {
builder.setOutcomeReason(chargeFailure.outcomeReason());
}
if (chargeFailure.outcomeType() != null) {
builder.setOutcomeType(chargeFailure.outcomeType());
}
return builder.build();
}
}

View File

@ -25,12 +25,10 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
@ -162,26 +160,4 @@ public class SubscriptionsUtil {
.increment();
}
public static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
final ChargeFailure chargeFailure) {
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
if (chargeFailure.outcomeNetworkStatus() != null) {
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
}
if (chargeFailure.outcomeReason() != null) {
builder.setOutcomeReason(chargeFailure.outcomeReason());
}
if (chargeFailure.outcomeType() != null) {
builder.setOutcomeType(chargeFailure.outcomeType());
}
return builder.build();
}
public static Locale getPayPalLocale(final List<Locale> acceptableLocales) {
return acceptableLocales.stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
}
}

View File

@ -29,10 +29,8 @@ import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -112,7 +110,7 @@ public class KeyTransparencyServiceClient implements Managed {
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public SearchResponseV2 search(
public SearchResponse search(
final ByteString aci,
final ByteString aciIdentityKey,
final Optional<ByteString> usernameHash,
@ -134,13 +132,13 @@ public class KeyTransparencyServiceClient implements Managed {
return search(searchRequestBuilder.build());
}
public SearchResponseV2 search(final SearchRequest request) {
public SearchResponse search(final SearchRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.searchV2(request);
.search(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public MonitorResponseV2 monitor(final AciMonitorRequest aciMonitorRequest,
public MonitorResponse monitor(final AciMonitorRequest aciMonitorRequest,
final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,
final Optional<E164MonitorRequest> e164MonitorRequest,
final long lastTreeHeadSize,
@ -157,11 +155,12 @@ public class KeyTransparencyServiceClient implements Managed {
return monitor(monitorRequestBuilder.build());
}
public MonitorResponseV2 monitor(final MonitorRequest request) {
public MonitorResponse monitor(final MonitorRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.monitorV2(request);
.monitor(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public DistinguishedResponse getDistinguishedKey(final Optional<Long> lastTreeHeadSize) {
final DistinguishedRequest request = lastTreeHeadSize.map(
@ -172,7 +171,7 @@ public class KeyTransparencyServiceClient implements Managed {
public DistinguishedResponse distinguished(final DistinguishedRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.distinguishedV2(request);
.distinguished(request);
}
private static Deadline toDeadline(final Duration timeout) {

View File

@ -28,7 +28,6 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
ALLOCATE_DEVICE("allocateDevice", new RateLimiterConfig(6, Duration.ofMinutes(2), false)),
VERIFY_DEVICE("verifyDevice", new RateLimiterConfig(6, Duration.ofMinutes(2), false)),
PROFILE("profile", new RateLimiterConfig(4320, Duration.ofSeconds(20), true)),
PROFILE_AVATAR_BYTES("profileAvatarBytes", new RateLimiterConfig(DataSize.mebibytes(100).toBytes(), Duration.ofNanos(10000), true)),
STICKER_PACK("stickerPack", new RateLimiterConfig(50, Duration.ofMinutes(72), false)),
USERNAME_LOOKUP("usernameLookup", new RateLimiterConfig(100, Duration.ofMinutes(15), true)),
USERNAME_SET("usernameSet", new RateLimiterConfig(100, Duration.ofMinutes(15), false)),
@ -135,10 +134,6 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
return forDescriptor(For.PROFILE);
}
public RateLimiter getProfileAvatarBytesLimiter() {
return forDescriptor(For.PROFILE_AVATAR_BYTES);
}
public RateLimiter getStickerPackLimiter() {
return forDescriptor(For.STICKER_PACK);
}

View File

@ -9,9 +9,12 @@ import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.micrometer.core.instrument.Metrics;
@ -30,7 +33,6 @@ import java.util.UUID;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.zkgroup.ZkCredentialPublicKey;
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -41,7 +43,6 @@ import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
import org.whispersystems.textsecuregcm.util.ZkCredentialPublicKeyAdapter;
@JsonFilter("Account")
public class Account {
@ -127,9 +128,7 @@ public class Account {
@JsonProperty("zck")
@Nullable
@JsonSerialize(using = ZkCredentialPublicKeyAdapter.Serializer.class)
@JsonDeserialize(using = ZkCredentialPublicKeyAdapter.Deserializer.class)
private ZkCredentialPublicKey zkCredentialKey;
private byte[] zkCredentialKey;
@JsonProperty("zckr")
@Nullable
@ -561,11 +560,12 @@ public class Account {
this.usernameHolds = usernameHolds;
}
public Optional<ZkCredentialPublicKey> getZkCredentialKey() {
return Optional.ofNullable(zkCredentialKey);
@Nullable
public byte[] getZkCredentialKey() {
return zkCredentialKey;
}
public void setZkCredentialKey(@Nullable final ZkCredentialPublicKey zkCredentialKey) {
public void setZkCredentialKey(@Nullable final byte[] zkCredentialKey) {
this.zkCredentialKey = zkCredentialKey;
}

View File

@ -327,8 +327,7 @@ public class Accounts {
accountToCreate.setBackupVoucher(existingAccount.getBackupVoucher());
// Carry over the existing ZK credential key to the new account
accountToCreate.setZkCredentialKey(existingAccount.getZkCredentialKey().orElse(null));
accountToCreate.setZkCredentialKeyRotationId(existingAccount.getZkCredentialKeyRotationId());
accountToCreate.setZkCredentialKey(existingAccount.getZkCredentialKey());
final List<TransactWriteItem> writeItems = new ArrayList<>();

View File

@ -158,24 +158,21 @@ public class ChangeNumberManager {
final Account updatedAccount = accountsManager.changeNumber(
account.getIdentifier(IdentityType.ACI), number, pniIdentityKey, deviceSignedPreKeys, devicePqLastResortPreKeys, pniRegistrationIds);
if (!messagesByDeviceId.isEmpty()) {
try {
// Now that we've actually updated the account, populate the "updated PNI" field on all envelopes
messagesByDeviceId.replaceAll((_, envelope) ->
envelope.toBuilder().setUpdatedPni(UUIDUtil.toByteString(updatedAccount.getIdentifier(IdentityType.PNI)))
.build());
try {
// Now that we've actually updated the account, populate the "updated PNI" field on all envelopes
messagesByDeviceId.replaceAll((_, envelope) ->
envelope.toBuilder().setUpdatedPni(UUIDUtil.toByteString(updatedAccount.getIdentifier(IdentityType.PNI)))
.build());
messageSender.sendMessages(updatedAccount,
serviceIdentifier,
messagesByDeviceId,
registrationIdsByDeviceId,
Optional.of(Device.PRIMARY_ID),
senderUserAgent);
} catch (final RuntimeException e) {
logger.warn("Changed number but could not send all device messages for {}",
account.getIdentifier(IdentityType.ACI), e);
throw e;
}
messageSender.sendMessages(updatedAccount,
serviceIdentifier,
messagesByDeviceId,
registrationIdsByDeviceId,
Optional.of(Device.PRIMARY_ID),
senderUserAgent);
} catch (final RuntimeException e) {
logger.warn("Changed number but could not send all device messages for {}", account.getIdentifier(IdentityType.ACI), e);
throw e;
}
return updatedAccount;

View File

@ -28,7 +28,6 @@ public class DonationPermitsManager {
/// Verifies and then spends the given {@link DonationPermit}
///
/// @return whether the spend was successful
/// @throws VerificationFailedException if the permit was not valid (expired or otherwise)
public boolean spend(final DonationPermit donationPermit) throws VerificationFailedException {
// Permits must be verified with the key pair that issued them, which can be re-derived from the embedded expiration

View File

@ -1,106 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.PutItemResponse;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;
public class ProfileAvatars {
private final DynamoDbClient dynamoDbClient;
private final String tableName;
private final Duration expiration;
private final Clock clock;
// Identity that owns this profile; hash key; byte array
@VisibleForTesting
static final String KEY_IDENTITY = "I";
// URL of the avatar; string
@VisibleForTesting
static final String ATTR_URL = "U";
// Expiration timestamp, in seconds since the epoch, of the avatar; number
@VisibleForTesting
static final String ATTR_TTL = "E";
public ProfileAvatars(final DynamoDbClient dynamoDbClient, final String tableName, final Duration expiration, final
Clock clock) {
this.dynamoDbClient = dynamoDbClient;
this.tableName = tableName;
this.expiration = expiration;
this.clock = clock;
}
/// Sets the avatar URL for an identity
///
/// @return the previous URL, if any
public Optional<String> setAvatarUrl(final byte[] identity, final String url) {
final PutItemResponse response = dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(KEY_IDENTITY, AttributeValues.fromByteArray(identity),
ATTR_URL, AttributeValues.fromString(url),
ATTR_TTL, AttributeValues.fromLong(clock.instant().plus(expiration).getEpochSecond())))
.returnValues(ReturnValue.ALL_OLD)
.build());
return Optional.ofNullable(AttributeValues.getString(response.attributes(), ATTR_URL, null));
}
/// @return the avatar URL, if present
public Optional<String> updateAvatarTtl(final byte[] identity) {
final UpdateItemResponse response;
try {
response = dynamoDbClient.updateItem(UpdateItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.conditionExpression("attribute_exists(#url)")
.updateExpression("SET #ttl = :ttl")
.expressionAttributeNames(Map.of(
"#ttl", ATTR_TTL,
"#url", ATTR_URL))
.expressionAttributeValues(
Map.of(":ttl", AttributeValues.fromLong(clock.instant().plus(expiration).getEpochSecond())))
.returnValues(ReturnValue.ALL_OLD)
.build());
} catch (ConditionalCheckFailedException _) {
return Optional.empty();
}
return Optional.ofNullable(AttributeValues.getString(response.attributes(), ATTR_URL, null));
}
/// @return the deleted avatar URL, if present
public Optional<String> deleteAvatarUrl(final byte[] identity) {
final DeleteItemResponse response = dynamoDbClient.deleteItem(DeleteItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.returnValues(ReturnValue.ALL_OLD)
.build());
if (!response.hasAttributes()) {
return Optional.empty();
}
return Optional.ofNullable(response.attributes().get(ATTR_URL))
.map(AttributeValue::s);
}
}

View File

@ -14,9 +14,7 @@ import io.lettuce.core.RedisException;
import io.lettuce.core.ScriptOutputType;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@ -31,13 +29,9 @@ import org.whispersystems.textsecuregcm.util.ResilienceUtil;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.Util;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.MetadataDirective;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
public class ProfilesManager {
@ -50,7 +44,6 @@ public class ProfilesManager {
private final Profiles profilesV1;
private final ProfilesV2 profilesV2;
private final ProfileAvatars profileAvatars;
private final FaultTolerantRedisClusterClient cacheCluster;
private final ScheduledExecutorService retryExecutor;
private final S3AsyncClient s3Client;
@ -64,19 +57,17 @@ public class ProfilesManager {
public ProfilesManager(final Profiles profilesV1,
final ProfilesV2 profilesV2,
final ProfileAvatars profileAvatars,
final FaultTolerantRedisClusterClient cacheCluster,
final ScheduledExecutorService retryExecutor,
final S3AsyncClient s3Client,
final String bucket) throws IOException {
this(profilesV1, profilesV2, profileAvatars, cacheCluster, retryExecutor, s3Client, bucket,
this(profilesV1, profilesV2, cacheCluster, retryExecutor, s3Client, bucket,
ClusterLuaScript.fromResource(cacheCluster, "lua/profile_set.lua", ScriptOutputType.STATUS));
}
@VisibleForTesting
ProfilesManager(final Profiles profilesV1,
final ProfilesV2 profilesV2,
final ProfileAvatars profileAvatars,
final FaultTolerantRedisClusterClient cacheCluster,
final ScheduledExecutorService retryExecutor,
final S3AsyncClient s3Client,
@ -84,7 +75,6 @@ public class ProfilesManager {
final ClusterLuaScript setLuaScript) {
this.profilesV1 = profilesV1;
this.profilesV2 = profilesV2;
this.profileAvatars = profileAvatars;
this.cacheCluster = cacheCluster;
this.retryExecutor = retryExecutor;
this.s3Client = s3Client;
@ -134,10 +124,12 @@ public class ProfilesManager {
redisSet(uuid, versionedProfile, versionedProfileV1);
}
/// Delete all profiles for the given uuid.
///
/// Avatars should be included for explicit delete actions, such as API calls and expired accounts. Implicit
/// deletions, such as registration, should preserve them, so that PIN recovery includes the avatar.
/**
* Delete all profiles for the given uuid.
* <p>
* Avatars should be included for explicit delete actions, such as API calls and expired accounts. Implicit
* deletions, such as registration, should preserve them, so that PIN recovery includes the avatar.
*/
public CompletableFuture<Void> deleteAll(UUID uuid, final boolean includeAvatar) {
final CompletableFuture<Void> profilesV1AndAvatars = Mono.fromFuture(profilesV1.deleteAll(uuid))
@ -157,7 +149,7 @@ public class ProfilesManager {
.bucket(bucket)
.key(avatar)
.build())
.whenComplete((_, throwable) -> {
.handle((ignored, throwable) -> {
final String outcome;
if (throwable != null) {
logger.warn("Error deleting avatar", throwable);
@ -167,6 +159,7 @@ public class ProfilesManager {
}
Metrics.counter(DELETE_AVATAR_COUNTER_NAME, "outcome", outcome).increment();
return null;
})
.thenRun(Util.NOOP);
}
@ -291,51 +284,4 @@ public class ProfilesManager {
static byte[] getCacheKeyV1(UUID uuid) {
return (CACHE_PREFIX_V1 + '{' + uuid.toString() + '}').getBytes();
}
public void deleteAvatarForIdentity(final byte[] identity) {
profileAvatars.deleteAvatarUrl(identity).ifPresent(avatar -> Mono.fromFuture(() ->
deleteAvatar(avatar))
.retry(3)
.onErrorComplete()
.block());
}
/// Sets an avatar for the identity. If there was already an avatar, it will be deleted from storage.
public void setAvatarForIdentity(byte[] identity, String avatar) {
profileAvatars.setAvatarUrl(identity, avatar).map(previousAvatar -> Mono.fromFuture(() ->
deleteAvatar(previousAvatar))
.retry(3)
.onErrorComplete()
.block());
}
public Optional<String> extendAvatarTtlForIdentity(final byte[] identity) {
final Optional<String> maybePath = profileAvatars.updateAvatarTtl(identity);
return maybePath.map(key -> {
try {
// copying the object to itself extends the expiration...
Mono.fromFuture(() -> s3Client.copyObject(CopyObjectRequest.builder()
.sourceBucket(bucket)
.sourceKey(key)
.destinationBucket(bucket)
.destinationKey(key)
.metadataDirective(MetadataDirective.REPLACE)
// ...but there needs to be a trivial change, otherwise it is rejected
.metadata(Map.of("t", String.valueOf(Instant.now().getEpochSecond())))
.build()))
.retryWhen(Retry.max(3).filter(e -> !(e instanceof NoSuchKeyException)))
.block();
return key;
} catch (NoSuchKeyException _) {
logger.warn("avatar expected to be present is gone");
profileAvatars.deleteAvatarUrl(identity);
return null;
}
});
}
}

View File

@ -9,14 +9,10 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.Util;
import reactor.adapter.JdkFlowAdapter;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
/// A [MessageStream] implementation that produces message from a joint DynamoDB/Redis message store.
public class RedisDynamoDbMessageStream implements MessageStream {
@ -29,14 +25,6 @@ public class RedisDynamoDbMessageStream implements MessageStream {
private final RedisDynamoDbMessagePublisher messagePublisher;
private static final String MESSAGE_READ_COUNTER_NAME =
name(RedisDynamoDbMessageStream.class, "messagesRead");
private static final String MESSAGE_ACKNOWLEDGED_COUNTER_NAME =
name(RedisDynamoDbMessageStream.class, "messagesAcknowledged");
private static final String UUID_VERSION_TAG = "uuidVersion";
public RedisDynamoDbMessageStream(final MessagesDynamoDb messagesDynamoDb,
final MessagesCache messagesCache,
final RedisMessageAvailabilityManager redisMessageAvailabilityManager,
@ -66,22 +54,11 @@ public class RedisDynamoDbMessageStream implements MessageStream {
@Override
public Flow.Publisher<MessageStreamEntry> getMessages() {
return JdkFlowAdapter.publisherToFlowPublisher(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)
.doOnNext(messageStreamEntry -> {
if (messageStreamEntry instanceof MessageStreamEntry.Envelope(org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope message)) {
final UUID messageGuid = UUIDUtil.fromByteString(message.getServerGuid());
Metrics.counter(MESSAGE_READ_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
.increment();
}
}));
return messagePublisher;
}
@Override
public CompletableFuture<Void> acknowledgeMessage(final UUID messageGuid, final long serverTimestamp) {
Metrics.counter(MESSAGE_ACKNOWLEDGED_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
.increment();
return messagesCache.remove(accountIdentifier, device.getId(), messageGuid)
.thenCompose(removed -> removed.map(_ -> CompletableFuture.<Void>completedFuture(null))
.orElseGet(() ->

View File

@ -85,7 +85,7 @@ class FoundationDbMessagePublisher {
/// Tracks the current state of the publisher state machine. Initial state presumes that messages are available in the queue.
private State state = State.MESSAGES_AVAILABLE;
/// Reference to the sink we publish messages to.
/// Reference to the sink we publishes messages to.
private volatile FluxSink<FoundationDbMessageStreamEntry.Message> emitter;
/// Future that completes when the watch for {@link #messagesAvailableWatchKey} triggers.
private CompletableFuture<Void> watchFuture;
@ -192,7 +192,6 @@ class FoundationDbMessagePublisher {
setState(State.MESSAGES_AVAILABLE, event);
transitionStateOnEvent(Event.INTERNAL_TRIGGER);
}
case MESSAGE_AVAILABLE_WATCH_TRIGGERED -> setState(State.MESSAGE_AVAILABLE_SIGNAL_BUFFERED, event);
case FETCH_OR_PUBLISH_ERROR_OCCURRED -> setState(State.ERROR, event);
default -> knownTransition = false;
}
@ -248,33 +247,27 @@ class FoundationDbMessagePublisher {
///
/// @return a future of a list of [FoundationDbMessageStreamEntry.Message] with a max size of [#maxMessagesPerScan]
private CompletableFuture<List<FoundationDbMessageStreamEntry.Message>> getMessagesBatch() {
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
return database.runAsync(transaction -> {
doBeforePageFetch.accept(transaction);
return getItemsInRange(transaction, beginKeyCursor, endKeyExclusive, maxMessagesPerScan)
.thenApply(lastKeyReadAndItems -> {
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan && !terminateOnQueueEmpty) {
setWatch(transaction);
// Set our beginning key to just past the last key read so that we're ready for our next fetch
lastKeyReadAndItems.first()
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
final List<FoundationDbMessageStreamEntry.Message> items = lastKeyReadAndItems.second();
if (items.size() < maxMessagesPerScan) {
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
if (!terminateOnQueueEmpty) {
setWatch(transaction);
}
}
return lastKeyReadAndItems;
return items;
});
})
// Defer any state mutations until after the transaction has been committed. The transaction block can
// fail/retry, and we don't want to trigger spurious state transitions when that happens.
.thenApply(lastKeyReadAndItems -> {
// Set our beginning key to just past the last key read so that we're ready for our next fetch
lastKeyReadAndItems.first()
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan) {
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
}
return lastKeyReadAndItems.second();
});
}
);
}
/// Fetch messages in the range between `begin` and `end` limited to a batch size of `maxMessagesPerSccan`

View File

@ -133,7 +133,7 @@ public class AppleAppStoreClient {
final APIError apiError = e.getApiError();
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, errorTags.and("reason", apiError != null ? apiError.name() : "http_" + e.getHttpStatusCode())).increment();
throw switch (e.getApiError()) {
case TRANSACTION_ID_NOT_FOUND, ORIGINAL_TRANSACTION_ID_NOT_FOUND, ACCOUNT_NOT_FOUND -> new SubscriptionNotFoundException();
case TRANSACTION_ID_NOT_FOUND, ORIGINAL_TRANSACTION_ID_NOT_FOUND -> new SubscriptionNotFoundException();
case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);
case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionInvalidArgumentsException(e.getApiErrorMessage());
case null, default -> throw e;
@ -183,7 +183,7 @@ public class AppleAppStoreClient {
private static boolean shouldRetry(Throwable e) {
return e instanceof APIException apiException && switch (apiException.getApiError()) {
case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE, ACCOUNT_NOT_FOUND_RETRYABLE -> true;
case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true;
case null, default -> false;
};
}

View File

@ -128,7 +128,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
return paymentMethod == PaymentMethod.PAYPAL;
}
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentId) {
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
@ -136,14 +136,14 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {
chargeFailure = createChargeFailure(transaction);
}
return Optional.of(new PaymentDetails(transaction.getGraphQLId(),
return new PaymentDetails(transaction.getGraphQLId(),
transaction.getCustomFields(),
getPaymentStatus(transaction.getStatus()),
transaction.getCreatedAt().toInstant(),
chargeFailure));
chargeFailure);
} catch (final NotFoundException e) {
return Optional.empty();
return null;
}
}, executor);
}

View File

@ -242,7 +242,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}, executor);
}
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentIntentId) {
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final PaymentIntent paymentIntent = getPaymentIntent(paymentIntentId);
@ -255,14 +255,14 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
}
return Optional.of(new PaymentDetails(paymentIntent.getId(),
return new PaymentDetails(paymentIntent.getId(),
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
getPaymentStatusForStatus(paymentIntent.getStatus()),
Instant.ofEpochSecond(paymentIntent.getCreated()),
chargeFailure));
chargeFailure);
} catch (StripeException e) {
if (e.getStatusCode() == 404) {
return Optional.empty();
return null;
} else {
throw new CompletionException(e);
}

View File

@ -1,56 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import java.util.Base64;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ZkCredentialPublicKey;
public class ZkCredentialPublicKeyAdapter {
public static class Serializer extends JsonSerializer<ZkCredentialPublicKey> {
@Override
public void serialize(final ZkCredentialPublicKey zkCredentialPublicKey,
final JsonGenerator jsonGenerator,
final SerializerProvider serializers) throws IOException {
jsonGenerator.writeString(Base64.getEncoder().encodeToString(zkCredentialPublicKey.serialize()));
}
}
public static class Deserializer extends JsonDeserializer<ZkCredentialPublicKey> {
@Override
public ZkCredentialPublicKey deserialize(final JsonParser parser, final DeserializationContext context) throws IOException {
final byte[] zkCredentialPublicKeyBytes;
try {
zkCredentialPublicKeyBytes = Base64.getDecoder().decode(parser.getValueAsString());
} catch (final IllegalArgumentException e) {
throw new JsonParseException(parser, "Could not parse key as a base64-encoded value", e);
}
if (zkCredentialPublicKeyBytes.length == 0) {
return null;
}
try {
return new ZkCredentialPublicKey(zkCredentialPublicKeyBytes);
} catch (final InvalidInputException e) {
// this should really never happen, as ZkCredentialPublicKey simply extends ByteArray
throw new JsonParseException(parser, "Could not interpret bytes as a ZK credential public key", e);
}
}
}
}

View File

@ -69,7 +69,6 @@ import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.ProfileAvatars;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ProfilesV2;
@ -267,8 +266,6 @@ public record CommandDependencies(
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profilesV1 = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfilesV1().getTableName());
ProfileAvatars profileAvatars = new ProfileAvatars(dynamoDbClient,
configuration.getDynamoDbTables().getProfileAvatars().getTableName(), RemoveExpiredAccountsCommand.MAX_IDLE_DURATION, clock);
ProfilesV2 profiles = new ProfilesV2(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfilesV2().getTableName());
S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()
@ -317,7 +314,7 @@ public record CommandDependencies(
new VersionstampUUIDCipher(configuration.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey(),
configuration.getFoundationDbMessagesConfiguration().versionstampCipherKeys().get(configuration.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey()).value()),
Clock.systemUTC());
ProfilesManager profilesManager = new ProfilesManager(profilesV1, profiles, profileAvatars, cacheCluster, retryExecutor, asyncCdnS3Client,
ProfilesManager profilesManager = new ProfilesManager(profilesV1, profiles, cacheCluster, retryExecutor, asyncCdnS3Client,
configuration.getCdnConfiguration().bucket());
ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getReportMessage().getTableName(),

View File

@ -34,17 +34,17 @@ service KeyTransparencyQueryService {
* subsequent Search and Monitor requests. It should be the first key
* transparency RPC a client calls.
*/
rpc DistinguishedV2(DistinguishedRequest) returns (DistinguishedResponse) {}
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
/**
* An endpoint used by clients to search for one or more identifiers in the transparency log.
* The server returns proof that the identifier(s) exist in the log.
*/
rpc SearchV2(SearchRequest) returns (SearchResponseV2) {}
rpc Search(SearchRequest) returns (SearchResponse) {}
/**
* An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
* constructed correctly in later entries for those identifiers.
*/
rpc MonitorV2(MonitorRequest) returns (MonitorResponseV2) {}
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
}
message SearchRequest {
@ -112,19 +112,6 @@ message SearchResponse {
optional CondensedTreeSearchResponse username_hash = 4;
}
/**
* Indicates that the client is not authorized to make the request because
* it did not provide the correct set of data.
*/
message PermissionDenied {}
message SearchResponseV2 {
oneof response {
SearchResponse search_response = 1;
PermissionDenied permission_denied = 2;
}
}
/**
* The tree head size(s) to prove consistency against. A client's very first
* key transparency request should be looking up the "distinguished" key;
@ -137,11 +124,13 @@ message ConsistencyParameters {
* This field may be omitted if the client is looking up an identifier
* for the first time.
*/
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
optional uint64 last = 1;
/**
* The distinguished tree head size to prove consistency against.
* This field may be omitted when the client is looking up the
* "distinguished" key for the very first time.
*/
uint64 distinguished = 2 [(org.signal.chat.require.range) = {min: 1}];
optional uint64 distinguished = 2;
}
/**
@ -154,7 +143,7 @@ message DistinguishedRequest {
* exception of a client's very first request, this field should always be
* set.
*/
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
optional uint64 last = 1;
}
/**
@ -354,20 +343,20 @@ message MonitorRequest {
message AciMonitorRequest {
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message UsernameHashMonitorRequest {
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message E164MonitorRequest {
string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message MonitorProof {
@ -408,10 +397,3 @@ message MonitorResponse {
*/
repeated bytes inclusion = 5;
}
message MonitorResponseV2 {
oneof response {
MonitorResponse monitor_response = 1;
PermissionDenied permission_denied = 2;
}
}

View File

@ -269,7 +269,7 @@ message LookupUsernameLinkResponse {
}
message SetZkCredentialKeyRequest {
// A serialized libsignal ZkCredentialPublicKey
// A serialized Ristretto key with a one-byte type prefix
bytes public_key = 1 [(require.exactlySize) = 33];
}

View File

@ -1,167 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.one_time_donations;
import "org/signal/chat/require.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/tag.proto";
import "org/signal/chat/subscriptions.proto";
// Service for making one-time donation payments (boost and gift)
//
// Note that these are siblings of the RPCs in the Subscriptions service. One-time payments do
// not require the subscription management methods in that service, though the configuration at
// Subscriptions.GetConfiguration is shared between subscription and one-time payments.
service OneTimeDonations {
option (require.auth) = AUTH_ONLY_ANONYMOUS;
// Create a Stripe payment intent and return a client secret that can be used to complete the payment.
// Once the payment is complete, the paymentIntentId can be used with CreateBoostReceiptCredentials
rpc CreateBoost(CreateBoostRequest) returns (CreateBoostResponse) {}
// Create a PayPal one-time payment.
// Once the payment is complete, call ConfirmPayPalBoost with the payment ID and token
rpc CreatePayPalBoost(CreatePayPalBoostRequest) returns (CreatePayPalBoostResponse) {}
// Confirm a PayPal one-time payment
rpc ConfirmPayPalBoost(ConfirmPayPalBoostRequest) returns (ConfirmPayPalBoostResponse) {}
// Obtain a ZK receipt credential for a completed one-time donation payment.
// The receipt credential can then be used to redeem the one-time donation entitlement
// via Donations.RedeemReceipt
rpc CreateBoostReceiptCredentials(CreateBoostReceiptCredentialsRequest) returns (CreateBoostReceiptCredentialsResponse) {}
}
// The amount is below the minimum for the currency.
message AmountBelowMinimumError {
// The minimum amount for the currency
string minimum = 1;
}
// The SEPA Direct Debit amount exceeds the allowed maximum.
message AmountAboveSepaLimitError {
// The maximum amount for a SEPA transaction
string maximum = 1;
}
message CreateBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// The amount to pay in the [currency's minor unit](https://docs.stripe.com/currencies#minor-units)
uint64 amount = 2 [(require.range).min = 1];
// The level for the boost payment
uint64 level = 3 [(require.range).min = 1];
// The payment method
subscriptions.PaymentMethod payment_method = 4 [(require.specified) = true];
// A donation permit retrieved from Donations.createDonationPermit
bytes donation_permit = 5 [(require.nonEmpty) = true];
}
message CreateBoostResponse {
oneof response {
// A client secret that can be used to complete a stripe PaymentIntent
string client_secret = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The amount exceeds the maximum for SEPA Direct Debit
AmountAboveSepaLimitError amount_above_sepa_limit = 3 [(tag.reason) = "amount_above_sepa_limit"];
// The requested currency is not supported for the given payment method
errors.FailedPrecondition unsupported_currency = 4 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 5 [(tag.reason) = "unsupported_level"];
// Donation permit was invalid or already spent
errors.FailedZkAuthentication permit_rejected = 6 [(tag.reason) = "permit_rejected"];
}
}
message CreatePayPalBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// Amount in the currency's minor unit (e.g. cents for USD), must be >= 1
uint64 amount = 2 [(require.range).min = 1];
// Donation level.
uint64 level = 3 [(require.range).min = 1];
// URL to redirect the user to after PayPal approval
string return_url = 4 [(require.nonEmpty) = true];
// URL to redirect the user to if they cancel
string cancel_url = 5 [(require.nonEmpty) = true];
}
message CreatePayPalBoostResponse {
message CreatePayPalBoostResult {
string approval_url = 1;
string payment_id = 2;
}
oneof response {
CreatePayPalBoostResult result = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The requested currency is not supported for PayPal
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
}
}
message ConfirmPayPalBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// Amount in the currency's minor unit, must be >= 1
uint64 amount = 2 [(require.range).min = 1];
// Donation level.
uint64 level = 3 [(require.range).min = 1];
// PayPal payer ID from the approval redirect
string payer_id = 4 [(require.nonEmpty) = true];
// PayPal payment ID (PAYID-) from CreatePayPalBoost
string payment_id = 5 [(require.nonEmpty) = true];
// PayPal payment token (EC-) from the approval redirect
string payment_token = 6 [(require.nonEmpty) = true];
}
message ConfirmPayPalBoostResponse {
message ConfirmPayPalBoostResult {
string payment_id = 1;
}
oneof response {
ConfirmPayPalBoostResult result = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The requested currency is not supported for PayPal
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
}
}
message CreateBoostReceiptCredentialsRequest {
// a payment ID from the processor
string payment_intent_id = 1 [(require.nonEmpty) = true];
// ZK blind-signature receipt credential request bytes
bytes receipt_credential_request = 2 [(require.nonEmpty) = true];
// The processor that handled the payment
subscriptions.PaymentProvider processor = 3 [(require.specified) = true];
}
message CreateBoostReceiptCredentialsResponse {
message CreateBoostReceiptCredentialsResult {
bytes receipt_credential_response = 1;
}
oneof response {
CreateBoostReceiptCredentialsResult result = 1;
// Payment is still processing; client should retry
errors.FailedPrecondition payment_still_processing = 2 [(tag.reason) = "payment_still_processing"];
// Payment failed
subscriptions.PaymentRequired payment_required = 3 [(tag.reason) = "payment_required"];
// Payment intent not found
errors.NotFound payment_not_found = 4 [(tag.reason) = "payment_not_found"];
// A receipt credential was already issued for this payment
errors.FailedPrecondition receipt_already_issued = 5 [(tag.reason) = "receipt_already_issued"];
}
}

View File

@ -9,8 +9,6 @@ option java_multiple_files = true;
package org.signal.chat.profile;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
@ -28,11 +26,6 @@ service Profile {
// Retrieves unversioned profile data. Callers with an unidentified access key for the account
// should use the version of this method in `ProfileAnonymous` instead.
rpc GetUnversionedProfile(GetUnversionedProfileRequest) returns (GetUnversionedProfileResponse) {}
// Returns anonymous credentials that may be presented with avatar operations in ProfilesAnonymous
//
// Note: `Accounts.SetZkCredentialKey` is a pre-requisite for this RPC
rpc GetAvatarCredentials(GetAvatarCredentialsRequest) returns (GetAvatarCredentialsResponse) {}
}
// Provides methods for working with profiles and profile-related data using "unidentified access"
@ -48,24 +41,6 @@ service ProfileAnonymous {
rpc GetUnversionedProfile(GetUnversionedProfileAnonymousRequest) returns (GetUnversionedProfileAnonymousResponse) {}
// Retrieves a profile key credential.
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialAnonymousResponse) {}
// Returns credentials to upload a v2 avatar. After uploading the avatar, the client
// must call SetProfile with the new avatar URL in the encrypted `data`.
//
// Because avatars are uploaded anonymously, they have an expiration equal to the
// idle account expiration. Clients must periodically (recommended: every 90 days)
// call ExtendAvatarTTL to extend the TTl.
//
// Note: any existing avatar associated with these credentials will be deleted immediately
rpc GetAvatarUploadForm(GetAvatarUploadFormRequest) returns (GetAvatarUploadFormResponse) {}
// Extends the TTL of the avatar currently associated with the requests avatar auth credential
rpc ExtendAvatarTTL(ExtendAvatarTTLRequest) returns (ExtendAvatarTTLResponse) {}
// Deletes the avatar currently associated with the requests avatar auth credential.
//
// Clients must also call SetProfile to remove the avatar from the encrypted `data`.
rpc DeleteAvatar(DeleteAvatarRequest) returns (DeleteAvatarResponse) {}
}
message SetProfileV1Request {
@ -350,59 +325,3 @@ enum CredentialType {
CREDENTIAL_TYPE_UNSPECIFIED = 0;
CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY = 1;
}
// avatar auth
message GetAvatarCredentialsRequest {
bytes avatar_credentials_request = 1;
}
message GetAvatarCredentialsResponse {
oneof response {
bytes avatar_credentials = 1;
// the client must call Accounts.SetZkCredentialKey to call this method
errors.FailedPrecondition missing_zk_credential_key = 2 [(tag.reason) = "missing_zk_credential_key"];
}
}
message GetAvatarUploadFormRequest {
bytes avatar_credentials_presentation = 1;
// The length of the attachment for the requested upload form. Uploads
// performed with this form will be limited to the provided length.
uint32 upload_length = 2 [(require.range) = {min: 1, max: 10485760 /* 10 MiB */}];
}
message GetAvatarUploadFormResponse {
oneof response {
common.S3UploadForm avatar_upload_form = 1;
errors.FailedZkAuthentication invalid_credentials_presentation = 2 [(tag.reason) = "invalid_credentials_presentation"];
}
}
message ExtendAvatarTTLRequest {
bytes avatar_credentials_presentation = 1;
}
message ExtendAvatarTTLResponse {
oneof response {
// The avatar that was extended. May be used as a check against state de-synchronization.
string path = 1;
errors.FailedZkAuthentication invalid_credentials_presentation = 2 [(tag.reason) = "invalid_credentials_presentation"];
// the identity does not have an active avatar
errors.NotFound not_found = 3 [(tag.reason) = "no_active_avatar"];
}
}
message DeleteAvatarRequest {
bytes avatar_credentials_presentation = 1;
}
message DeleteAvatarResponse {
oneof response {
google.protobuf.Empty success = 1;
errors.FailedZkAuthentication invalid_credentials_presentation = 2 [(tag.reason) = "invalid_credentials_presentation"];
}
}

View File

@ -109,8 +109,8 @@ service Subscriptions {
message UpdateSubscriberRequest {
bytes subscriber_id = 1 [(require.exactlySize) = 32];
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit.
// Not required if the subscriber already exists.
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit,
// required if the subscriber ID is new
bytes donation_permit = 2;
}
@ -295,10 +295,6 @@ message ChargeFailure {
optional string outcome_type = 6;
}
message PaymentRequired {
optional ChargeFailure charge_failure = 1;
}
message SetSubscriptionLevelResponse {
message SetSubscriptionLevelResult {
uint64 level = 1;
@ -371,6 +367,10 @@ message GetReceiptCredentialsResponse {
bytes receiptCredentialResponse = 1;
}
message PaymentRequired {
optional ChargeFailure charge_failure = 1;
}
oneof response {
GetReceiptCredentialsResult success = 1;
errors.NotFound subscriber_not_found = 2 [(tag.reason) = "subscriber_not_found"];

View File

@ -10,31 +10,27 @@ import com.apple.foundationdb.FDB;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonTypeName;
import java.io.IOException;
import org.whispersystems.textsecuregcm.storage.FoundationDbDatabaseLifecycleManager;
import org.whispersystems.textsecuregcm.storage.ServiceContainerFoundationDbDatabaseLifecycleManager;
import org.whispersystems.textsecuregcm.storage.TestcontainersFoundationDbDatabaseLifecycleManager;
@JsonTypeName("local")
public class LocalFoundationDbDatabaseFactory implements FoundationDbDatabaseFactory {
@JsonIgnore
private final TestcontainersFoundationDbDatabaseLifecycleManager testcontainersFoundationDbDatabaseLifecycleManager;
@JsonIgnore
private Database database;
private LocalFoundationDbDatabaseFactory() {
this.testcontainersFoundationDbDatabaseLifecycleManager = new TestcontainersFoundationDbDatabaseLifecycleManager();
}
@Override
public synchronized Database build(final FDB fdb) throws IOException {
if (database == null) {
final String serviceContainerNamePrefix = System.getProperty("foundationDb.serviceContainerNamePrefix");
final FoundationDbDatabaseLifecycleManager lifecycleManager = serviceContainerNamePrefix != null
? new ServiceContainerFoundationDbDatabaseLifecycleManager(serviceContainerNamePrefix + "0")
: new TestcontainersFoundationDbDatabaseLifecycleManager();
Runtime.getRuntime().addShutdownHook(new Thread(lifecycleManager::closeDatabase));
lifecycleManager.initializeDatabase(fdb);
database = lifecycleManager.getDatabase();
Runtime.getRuntime().addShutdownHook(new Thread(testcontainersFoundationDbDatabaseLifecycleManager::closeDatabase));
testcontainersFoundationDbDatabaseLifecycleManager.initializeDatabase(fdb);
database = testcontainersFoundationDbDatabaseLifecycleManager.getDatabase();
}
return database;

View File

@ -54,10 +54,8 @@ import org.signal.keytransparency.client.DistinguishedResponse;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.FullTreeHead;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchProof;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UpdateValue;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
@ -145,10 +143,7 @@ public class KeyTransparencyControllerTest {
usernameHash.ifPresent(ignored -> searchResponseBuilder.setUsernameHash(CondensedTreeSearchResponse.getDefaultInstance()));
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), anyLong()))
.thenReturn(SearchResponseV2.newBuilder()
.setSearchResponse(searchResponseBuilder.build())
.build()
);
.thenReturn(searchResponseBuilder.build());
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/key-transparency/search")
@ -296,9 +291,7 @@ public class KeyTransparencyControllerTest {
@Test
void monitorSuccess() {
when(keyTransparencyServiceClient.monitor(any(), any(), any(), anyLong(), anyLong()))
.thenReturn(MonitorResponseV2.newBuilder()
.setMonitorResponse(MonitorResponse.getDefaultInstance())
.build());
.thenReturn(MonitorResponse.getDefaultInstance());
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/key-transparency/monitor")
@ -313,7 +306,7 @@ public class KeyTransparencyControllerTest {
final KeyTransparencyMonitorResponse keyTransparencyMonitorResponse = response.readEntity(
KeyTransparencyMonitorResponse.class);
assertArrayEquals(MonitorResponse.getDefaultInstance().toByteArray(), keyTransparencyMonitorResponse.serializedResponse());
assertNotNull(keyTransparencyMonitorResponse.serializedResponse());
verify(keyTransparencyServiceClient, times(1)).monitor(
any(), any(), any(), eq(3L), eq(4L));

View File

@ -25,7 +25,6 @@ import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
@ -240,12 +239,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
@ParameterizedTest
@MethodSource
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.FAILED,
Instant.now(),
chargeFailure))
chargeFailure)
));
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
@ -323,12 +322,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.SUCCEEDED,
Instant.now(),
null))));
null)));
doThrow(WriteConflictException.class).when(ISSUED_RECEIPTS_MANAGER).recordIssuance(any(), any(), any(), any());
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
@ -60,8 +61,6 @@ import org.signal.chat.account.UsernameNotAvailable;
import org.signal.chat.common.AccountIdentifiers;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.zkgroup.ZkCredentialKeyPair;
import org.signal.libsignal.zkgroup.ZkCredentialPublicKey;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.controllers.AccountController;
@ -708,15 +707,15 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
@ParameterizedTest
@ValueSource(booleans = {true, false})
void setZkCredentialKey(final boolean matchesCurrentZkCredentialKey) throws Exception {
void setZkCredentialKey(final boolean matchesCurrentZkCredentialKey) {
final ZkCredentialPublicKey publicKey = ZkCredentialKeyPair.generate().getPublicKey();
final byte[] publicKey = TestRandomUtil.nextBytes(33);
final long rotationId = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE);
final Account account = mock(Account.class);
if (matchesCurrentZkCredentialKey) {
when(account.getZkCredentialKey()).thenReturn(Optional.of(publicKey));
when(account.getZkCredentialKey()).thenReturn(publicKey);
when(account.getZkCredentialKeyRotationId()).thenReturn(rotationId);
}
@ -725,7 +724,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
final SetZkCredentialKeyResponse response = assertDoesNotThrow(() ->
authenticatedServiceStub().setZkCredentialKey(SetZkCredentialKeyRequest.newBuilder()
.setPublicKey(ByteString.copyFrom(publicKey.serialize()))
.setPublicKey(ByteString.copyFrom(publicKey))
.build()));
if (matchesCurrentZkCredentialKey) {
@ -737,13 +736,13 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
final int updateMethodCalls = matchesCurrentZkCredentialKey ? 0 : 1;
verify(accountsManager, times(updateMethodCalls)).update(eq(AUTHENTICATED_ACI), any());
verify(account, times(updateMethodCalls)).setZkCredentialKey(publicKey);
verify(account, times(updateMethodCalls)).setZkCredentialKey(aryEq(publicKey));
}
@Test
void setZkCredentialKeyRateLimited() throws Exception {
final byte[] publicKey = ZkCredentialKeyPair.generate().getPublicKey().serialize();
final byte[] publicKey = TestRandomUtil.nextBytes(33);
final Duration retryDuration = Duration.ofDays(1);
final Account account = mock(Account.class);

View File

@ -23,9 +23,9 @@ import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@ -56,7 +56,7 @@ import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimi
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import static org.whispersystems.textsecuregcm.grpc.KeyTransparencyGrpcService.COMMITMENT_INDEX_LENGTH;
@SuppressWarnings({"OptionalUsedAsFieldOrParameterType", "ThrowableNotThrown", "ResultOfMethodCallIgnored"})
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransparencyGrpcService, KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub>{
@Mock
private KeyTransparencyServiceClient keyTransparencyServiceClient;
@ -80,7 +80,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
@Test
void searchSuccess() throws RateLimitExceededException {
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponseV2.getDefaultInstance());
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponse.getDefaultInstance());
Mockito.doNothing().when(rateLimiter).validate(any(String.class));
final SearchRequest request = SearchRequest.newBuilder()
.setAci(ByteString.copyFrom(ACI.toCompactByteArray()))
@ -90,7 +90,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.build())
.build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().searchV2(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().search(request));
verify(keyTransparencyServiceClient, times(1)).search(eq(request));
}
@ -121,7 +121,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
lastTreeHeadSize.ifPresent(consistencyBuilder::setLast);
requestBuilder.setConsistency(consistencyBuilder.build());
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().searchV2(requestBuilder.build()));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().search(requestBuilder.build()));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -152,13 +152,13 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setDistinguished(10)
.build())
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().searchV2(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().search(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@Test
void monitorSuccess() {
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponseV2.getDefaultInstance());
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponse.getDefaultInstance());
when(rateLimiter.validateReactive(any(String.class)))
.thenReturn(Mono.empty());
final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()
@ -175,7 +175,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.build())
.build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitorV2(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitor(request));
verify(keyTransparencyServiceClient, times(1)).monitor(eq(request));
}
@ -199,7 +199,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
requestBuilder.setConsistency(consistencyBuilder.build());
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitorV2(requestBuilder.build()));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitor(requestBuilder.build()));
}
private static Stream<Arguments> monitorInvalidRequest() {
@ -218,9 +218,9 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
Arguments.argumentSet("Invalid username hash length", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(new byte[31], new byte[32], 10)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("Invalid commitment index on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[31], 10)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("Invalid entry position on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[32], 0)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L),
Arguments.argumentSet("consistency.last must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(0L), Optional.of(4L)),
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.empty()),
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L)), Optional.empty()),
Arguments.argumentSet("consistency.distinguished must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(0L))
);
}
@ -243,7 +243,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(10)
.build())
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitorV2(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitor(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -254,7 +254,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.thenReturn(Mono.empty());
final DistinguishedRequest request = DistinguishedRequest.newBuilder().build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguishedV2(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguished(request));
verify(keyTransparencyServiceClient, times(1)).distinguished(eq(request));
}
@ -264,7 +264,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(0)
.build();
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguishedV2(request));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguished(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -277,7 +277,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(10)
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguishedV2(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguished(request));
verifyNoInteractions(keyTransparencyServiceClient);
}

View File

@ -1,336 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.net.InetAddresses;
import com.google.protobuf.ByteString;
import com.stripe.model.PaymentIntent;
import jakarta.annotation.Nullable;
import java.time.Instant;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
import org.signal.chat.one_time_donations.CreateBoostRequest;
import org.signal.chat.one_time_donations.CreateBoostResponse;
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
import org.signal.chat.one_time_donations.OneTimeDonationsGrpc;
import org.signal.chat.subscriptions.PaymentMethod;
import org.signal.chat.subscriptions.PaymentProvider;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext;
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.DonationPermits;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.tests.util.SubscriptionConfigTestHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
public class OneTimeDonationsGrpcServiceTest extends
SimpleBaseGrpcTest<OneTimeDonationsGrpcService, OneTimeDonationsGrpc.OneTimeDonationsBlockingStub> {
private final TestClock clock = TestClock.pinned(Instant.now());
private final OneTimeDonationConfiguration oneTimeDonationConfiguration =
SubscriptionConfigTestHelper.getOneTimeConfig();
@Mock
private DonationPermits donationPermits;
private static final ServerSecretParams DONATION_PERMITS_SECRET_PARAMS = ServerSecretParams.generate();
private DonationPermitsManager donationPermitsManager;
@Mock
private StripeManager stripeManager;
@Mock
private BraintreeManager braintreeManager;
@Mock
private PayPalDonationsTranslator payPalDonationsTranslator;
@Mock
private OneTimeDonationsManager oneTimeDonationsManager;
@Mock
private IssuedReceiptsManager issuedReceiptsManager;
@Mock
private ServerZkReceiptOperations zkReceiptOperations;
@Mock
private RateLimiters rateLimiters;
@Mock
private RateLimiter rateLimiter;
@Override
protected OneTimeDonationsGrpcService createServiceBeforeEachTest() {
getMockRequestAttributesInterceptor().setRequestAttributes(
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-us"));
donationPermitsManager = new DonationPermitsManager(donationPermits, DONATION_PERMITS_SECRET_PARAMS, clock);
// spendIds are spend-once
final Set<String> spent = new HashSet<>();
when(donationPermits.spend(any(byte[].class), any(Instant.class)))
.thenAnswer(answer -> spent.add(new String(answer.getArgument(0, byte[].class))));
when(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)).thenReturn(rateLimiter);
return new OneTimeDonationsGrpcService(
oneTimeDonationConfiguration,
stripeManager,
braintreeManager,
payPalDonationsTranslator,
oneTimeDonationsManager,
issuedReceiptsManager,
zkReceiptOperations,
clock,
rateLimiters,
donationPermitsManager);
}
@Test
void createBoost() {
final PaymentIntent paymentIntent = mock(PaymentIntent.class);
when(paymentIntent.getClientSecret()).thenReturn("test-client-secret");
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
.thenReturn(Set.of("usd"));
when(stripeManager.createPaymentIntent(any(), anyLong(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(paymentIntent));
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(500)
.setLevel(1)
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(CreateBoostResponse.ResponseCase.CLIENT_SECRET, response.getResponseCase());
assertEquals("test-client-secret", response.getClientSecret());
}
@Test
void createBoostPermitAlreadySpent() {
when(donationPermits.spend(any(byte[].class), any(Instant.class))).thenReturn(false);
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(500)
.setLevel(1)
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(CreateBoostResponse.ResponseCase.PERMIT_REJECTED, response.getResponseCase());
}
@ParameterizedTest
@MethodSource
void createBoostValidationErrors(
final String currency,
final long amount,
final PaymentMethod paymentMethod,
final CreateBoostResponse.ResponseCase expectedResponseCase) {
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
.thenReturn(Set.of("usd"));
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency(currency)
.setAmount(amount)
.setLevel(1)
.setPaymentMethod(paymentMethod)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(expectedResponseCase, response.getResponseCase());
}
static Stream<Arguments> createBoostValidationErrors() {
return Stream.of(
Arguments.of("usd", 249L, PaymentMethod.PAYMENT_METHOD_CARD,
CreateBoostResponse.ResponseCase.AMOUNT_BELOW_MINIMUM),
Arguments.of("eur", 1000001L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
CreateBoostResponse.ResponseCase.AMOUNT_ABOVE_SEPA_LIMIT),
// USD is not supported for SEPA_DEBIT
Arguments.of("usd", 3000L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
CreateBoostResponse.ResponseCase.UNSUPPORTED_CURRENCY)
);
}
@Test
void createPayPalBoost() {
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
mock(BraintreeManager.PayPalOneTimePaymentApprovalDetails.class);
when(approvalDetails.approvalUrl()).thenReturn("test-approval-url");
when(approvalDetails.paymentId()).thenReturn("test-id");
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd"));
when(payPalDonationsTranslator.translate(any(), any()))
.thenReturn("Donation to Signal Technology Foundation");
when(braintreeManager.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(),
anyString()))
.thenReturn(CompletableFuture.completedFuture(approvalDetails));
final CreatePayPalBoostResponse response = unauthenticatedServiceStub().createPayPalBoost(
CreatePayPalBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(300)
.setLevel(1)
.setReturnUrl("returnUrl")
.setCancelUrl("cancelUrl")
.build());
assertEquals(CreatePayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
assertEquals("test-approval-url", response.getResult().getApprovalUrl());
assertEquals("test-id", response.getResult().getPaymentId());
}
@Test
void confirmPayPalBoost() {
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd"));
when(braintreeManager.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(
new BraintreeManager.PayPalChargeSuccessDetails("test-id")));
final ConfirmPayPalBoostResponse response = unauthenticatedServiceStub().confirmPayPalBoost(
ConfirmPayPalBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(300)
.setLevel(1)
.setPayerId("test-payer-id")
.setPaymentId("test-payment-id")
.setPaymentToken("test-payment-token")
.build());
assertEquals(ConfirmPayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
assertEquals("test-id", response.getResult().getPaymentId());
}
@ParameterizedTest
@MethodSource
void createBoostReceiptCredentialsPaymentRequired(
@Nullable final ChargeFailure chargeFailure,
final boolean expectChargeFailure) {
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.FAILED,
clock.instant(), chargeFailure))));
final CreateBoostReceiptCredentialsResponse response =
unauthenticatedServiceStub().createBoostReceiptCredentials(
CreateBoostReceiptCredentialsRequest.newBuilder()
.setPaymentIntentId("test-payment-intent-id")
.setReceiptCredentialRequest(ByteString.copyFromUtf8("abcd"))
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
.build());
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.PAYMENT_REQUIRED,
response.getResponseCase());
if (expectChargeFailure) {
assertEquals("generic_decline", response.getPaymentRequired().getChargeFailure().getCode());
} else {
assertFalse(response.getPaymentRequired().hasChargeFailure());
}
}
static Stream<Arguments> createBoostReceiptCredentialsPaymentRequired() {
return Stream.of(
Arguments.of(new ChargeFailure("generic_decline", "some failure message", null, null, null), true),
Arguments.of(null, false)
);
}
@Test
void createBoostReceiptCredentialsAlreadyRedeemed() throws Exception {
final ReceiptCredentialRequest receiptCredentialRequest = new ClientZkReceiptOperations(
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.SUCCEEDED,
clock.instant(), null))));
doThrow(WriteConflictException.class).when(issuedReceiptsManager)
.recordIssuance(any(), any(), any(), any());
final CreateBoostReceiptCredentialsResponse response =
unauthenticatedServiceStub().createBoostReceiptCredentials(
CreateBoostReceiptCredentialsRequest.newBuilder()
.setPaymentIntentId("test-payment-intent-id")
.setReceiptCredentialRequest(ByteString.copyFrom(receiptCredentialRequest.serialize()))
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
.build());
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.RECEIPT_ALREADY_ISSUED,
response.getResponseCase());
}
private DonationPermit getDonationPermit() {
final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(1);
final DonationPermitRequest permitRequest = context.request();
final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest);
try {
final List<DonationPermit> permits = context.receive(
permitResponse, DONATION_PERMITS_SECRET_PARAMS.getPublicParams(), clock.instant());
return permits.getFirst();
} catch (final VerificationFailedException e) {
throw new AssertionError("The permit was correctly requested and issued in this method", e);
}
}
}

View File

@ -13,8 +13,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
@ -22,15 +20,12 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusInvalidArgument;
import com.google.common.net.InetAddresses;
import com.google.protobuf.ByteString;
import com.google.rpc.BadRequest;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
@ -43,7 +38,6 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.junit.jupiter.api.Test;
@ -56,12 +50,6 @@ import org.signal.chat.common.IdentityType;
import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.CredentialType;
import org.signal.chat.profile.DataEtag;
import org.signal.chat.profile.DeleteAvatarRequest;
import org.signal.chat.profile.DeleteAvatarResponse;
import org.signal.chat.profile.ExtendAvatarTTLRequest;
import org.signal.chat.profile.ExtendAvatarTTLResponse;
import org.signal.chat.profile.GetAvatarUploadFormRequest;
import org.signal.chat.profile.GetAvatarUploadFormResponse;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousResponse;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest;
@ -78,13 +66,8 @@ import org.signal.chat.profile.ProfileAnonymousGrpc;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.ZkCredentialKeyPair;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialPresentation;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialRequestContext;
import org.signal.libsignal.zkgroup.avatars.AvatarUploadCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
@ -95,13 +78,9 @@ import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
@ -110,8 +89,6 @@ import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.storage.VersionedProfileV1;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
@ -131,15 +108,8 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
@Mock
private ProfileBadgeConverter profileBadgeConverter;
@Mock
private RateLimiter profileAvatarBytesRateLimiter;
private ServerZkProfileOperations zkProfileOperations;
private final GenericServerSecretParams genericServerSecretParams = GenericServerSecretParams.generate();
private MutableClock clock;
@Override
protected ProfileAnonymousGrpcService createServiceBeforeEachTest() {
getMockRequestAttributesInterceptor().setRequestAttributes(new RequestAttributes(InetAddresses.forString("127.0.0.1"),
@ -148,21 +118,12 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
zkProfileOperations = spy(new ServerZkProfileOperations(SERVER_SECRET_PARAMS));
final RateLimiters rateLimiters = mock(RateLimiters.class);
when(rateLimiters.getProfileAvatarBytesLimiter()).thenReturn(profileAvatarBytesRateLimiter);
clock = new MutableClock();
return new ProfileAnonymousGrpcService(
accountsManager,
profilesManager,
profileBadgeConverter,
new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey", "accessSecret"),
genericServerSecretParams,
rateLimiters,
clock,
zkProfileOperations,
new GroupSendTokenUtil(SERVER_SECRET_PARAMS, clock)
new GroupSendTokenUtil(SERVER_SECRET_PARAMS, Clock.systemUTC())
);
}
@ -827,182 +788,12 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
);
}
@Test
void getAvatarUploadForm() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final GetAvatarUploadFormRequest request = GetAvatarUploadFormRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.setUploadLength(100)
.build();
final GetAvatarUploadFormResponse response = authenticatedServiceStub().getAvatarUploadForm(request);
assertTrue(response.hasAvatarUploadForm());
final String avatarPath = response.getAvatarUploadForm().getKey();
assertFalse(avatarPath.isEmpty());
verify(profilesManager).setAvatarForIdentity(aryEq(avatarUploadCredentialPresentation.getCommitment()), eq(avatarPath));
}
@Test
void getAvatarUploadFormInvalidUploadLength() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final GetAvatarUploadFormRequest request = GetAvatarUploadFormRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.setUploadLength(Math.toIntExact(ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES + 1))
.build();
final StatusRuntimeException statusRuntimeException = assertStatusInvalidArgument(
() -> authenticatedServiceStub().getAvatarUploadForm(request));
final List<BadRequest.FieldViolation> fieldViolations = GrpcTestUtils.extractDetail(BadRequest.class, statusRuntimeException)
.getFieldViolationsList();
assertEquals(1, fieldViolations.size());
assertEquals("upload_length", fieldViolations.getFirst().getField());
}
@Test
void getAvatarUploadFormInvalidCredentialsPresentation() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final GetAvatarUploadFormRequest request = GetAvatarUploadFormRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.setUploadLength(100)
.build();
// trigger a verification failure by advancing the clock beyond validity
clock.setTimeInstant(clock.instant().plus(Duration.ofDays(3)));
final GetAvatarUploadFormResponse response = authenticatedServiceStub().getAvatarUploadForm(request);
assertTrue(response.hasInvalidCredentialsPresentation());
}
@Test
void getAvatarUploadFormRateLimited() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final Duration retryDuration = Duration.ofMinutes(20);
doThrow(new RateLimitExceededException(retryDuration))
.when(profileAvatarBytesRateLimiter).validate(anyString(), anyLong());
final GetAvatarUploadFormRequest request = GetAvatarUploadFormRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.setUploadLength(100)
.build();
assertRateLimitExceeded(retryDuration,
() -> authenticatedServiceStub().getAvatarUploadForm(request),
profilesManager);
}
@Test
void extendAvatarTtl() throws Exception {
final String path = "somePath";
when(profilesManager.extendAvatarTtlForIdentity(any(byte[].class))).thenReturn(Optional.of(path));
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final ExtendAvatarTTLRequest request = ExtendAvatarTTLRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.build();
final ExtendAvatarTTLResponse response = authenticatedServiceStub().extendAvatarTTL(request);
assertTrue(response.hasPath());
assertEquals(path, response.getPath());
}
@Test
void extendAvatarTtlNoActiveAvatar() throws Exception {
when(profilesManager.extendAvatarTtlForIdentity(any(byte[].class))).thenReturn(Optional.empty());
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final ExtendAvatarTTLRequest request = ExtendAvatarTTLRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.build();
final ExtendAvatarTTLResponse response = authenticatedServiceStub().extendAvatarTTL(request);
assertTrue(response.hasNotFound());
}
@Test
void extendAvatarTtlInvalidCredentialsPresentation() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final ExtendAvatarTTLRequest request = ExtendAvatarTTLRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.build();
// trigger a verification failure by advancing the clock beyond validity
clock.setTimeInstant(clock.instant().plus(Duration.ofDays(3)));
final ExtendAvatarTTLResponse response = authenticatedServiceStub().extendAvatarTTL(request);
assertTrue(response.hasInvalidCredentialsPresentation());
}
@Test
void deleteAvatar() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final DeleteAvatarRequest request = DeleteAvatarRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.build();
final DeleteAvatarResponse response = authenticatedServiceStub().deleteAvatar(request);
assertTrue(response.hasSuccess());
verify(profilesManager).deleteAvatarForIdentity(aryEq(avatarUploadCredentialPresentation.getCommitment()));
}
@Test
void deleteAvatarInvalidCredentialsPresentation() throws Exception {
final AvatarUploadCredentialPresentation avatarUploadCredentialPresentation = getAvatarUploadCredentialPresentation(
genericServerSecretParams, clock);
final DeleteAvatarRequest request = DeleteAvatarRequest.newBuilder()
.setAvatarCredentialsPresentation(ByteString.copyFrom(avatarUploadCredentialPresentation.serialize()))
.build();
// trigger a verification failure by advancing the clock beyond validity
clock.setTimeInstant(clock.instant().plus(Duration.ofDays(3)));
final DeleteAvatarResponse response = authenticatedServiceStub().deleteAvatar(request);
assertTrue(response.hasInvalidCredentialsPresentation());
}
static AvatarUploadCredentialPresentation getAvatarUploadCredentialPresentation(final GenericServerSecretParams serverSecretParams, final Clock clock) throws VerificationFailedException {
final ServiceId.Aci aci = new ServiceId.Aci(UUID.randomUUID());
final ZkCredentialKeyPair zkCredentialKeyPair = ZkCredentialKeyPair.generate();
final long rotationId = ThreadLocalRandom.current().nextLong();
final AvatarUploadCredentialRequestContext context = AvatarUploadCredentialRequestContext.create(
aci,
zkCredentialKeyPair, rotationId);
final AvatarUploadCredentialResponse avatarUploadCredentialResponse = context.getRequest()
.issueCredential(aci, zkCredentialKeyPair.getPublicKey(), rotationId, clock.instant().truncatedTo(ChronoUnit.DAYS), serverSecretParams);
return context.receiveResponse(avatarUploadCredentialResponse, clock.instant(),
serverSecretParams.getPublicParams()).present(serverSecretParams.getPublicParams());
@Override
protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {
return serverInterceptors.stream()
// For now, don't validate error conformance because the profiles gRPC service has not been converted to the
// updated error model
.filter(interceptor -> !(interceptor instanceof ErrorConformanceInterceptor))
.toList();
}
}

View File

@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -32,6 +31,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import com.google.protobuf.ByteString;
import com.google.rpc.BadRequest;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.time.Clock;
@ -60,8 +60,6 @@ import org.mockito.Mock;
import org.signal.chat.common.IdentityType;
import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.DataEtag;
import org.signal.chat.profile.GetAvatarCredentialsRequest;
import org.signal.chat.profile.GetAvatarCredentialsResponse;
import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetUnversionedProfileResult;
@ -79,12 +77,7 @@ import org.signal.chat.profile.test.PlaintextProfileData;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ZkCredentialKeyPair;
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.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
@ -113,7 +106,6 @@ import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
@ -130,8 +122,6 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
.setPhoneNumberSharing(ByteString.copyFrom(TestRandomUtil.nextBytes(29)))
.build();
private final GenericServerSecretParams genericServerSecretParams = GenericServerSecretParams.generate();
@Mock
private AccountsManager accountsManager;
@ -153,11 +143,11 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
@Mock
private ProfileBadgeConverter profileBadgeConverter;
private MutableClock clock;
private Clock clock;
@Override
protected ProfileGrpcService createServiceBeforeEachTest() {
clock = new MutableClock(Clock.fixed(Instant.ofEpochSecond(42), ZoneId.of("Etc/UTC")));
clock = Clock.fixed(Instant.ofEpochSecond(42), ZoneId.of("Etc/UTC"));
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
@ -218,7 +208,6 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
dynamicConfigurationManager,
badgesConfiguration,
policyGenerator,
genericServerSecretParams,
profileBadgeConverter,
rateLimiters
);
@ -904,77 +893,13 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getVersionedProfile(request));
}
@Test
void getAvatarCredential() {
final ZkCredentialKeyPair zkCredentialKeyPair = ZkCredentialKeyPair.generate();
final long rotationId = 617L;
// this test needs a valid clock
clock.setTimeMillis(Instant.now().toEpochMilli());
when(account.getZkCredentialKey()).thenReturn(Optional.of(zkCredentialKeyPair.getPublicKey()));
when(account.getZkCredentialKeyRotationId()).thenReturn(rotationId);
final AvatarUploadCredentialRequestContext avatarUploadCredentialRequestContext = AvatarUploadCredentialRequestContext.create(
new ServiceId.Aci(AUTHENTICATED_ACI), zkCredentialKeyPair, rotationId);
final AvatarUploadCredentialRequest credentialRequest = avatarUploadCredentialRequestContext.getRequest();
final GetAvatarCredentialsResponse response = authenticatedServiceStub().getAvatarCredentials(
GetAvatarCredentialsRequest.newBuilder()
.setAvatarCredentialsRequest(ByteString.copyFrom(credentialRequest.serialize()))
.build());
assertTrue(response.hasAvatarCredentials());
assertDoesNotThrow(() -> avatarUploadCredentialRequestContext.receiveResponse(
new AvatarUploadCredentialResponse(response.getAvatarCredentials().toByteArray()),
genericServerSecretParams.getPublicParams()));
}
@Test
void getAvatarCredentialAccountNotFound() {
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.empty());
final StatusRuntimeException statusRuntimeException = assertStatusException(Status.UNAUTHENTICATED,
() -> authenticatedServiceStub().getAvatarCredentials(GetAvatarCredentialsRequest.getDefaultInstance()));
assertEquals("account not found", statusRuntimeException.getStatus().getDescription());
}
@Test
void getAvatarCredentialMissingZkCredentialKey() {
when(account.getZkCredentialKey()).thenReturn(Optional.empty());
final GetAvatarCredentialsResponse response = authenticatedServiceStub().getAvatarCredentials(
GetAvatarCredentialsRequest.getDefaultInstance());
assertTrue(response.hasMissingZkCredentialKey());
assertEquals("account requires ZK credential key", response.getMissingZkCredentialKey().getDescription());
}
@Test
void getAvatarCredentialInvalidCredentialRequest() {
final ZkCredentialKeyPair zkCredentialKeyPair = ZkCredentialKeyPair.generate();
final long rotationId = 617L;
final long incorrectRotationId = rotationId + 1;
// this test needs a valid clock
clock.setTimeMillis(Instant.now().toEpochMilli());
when(account.getZkCredentialKey()).thenReturn(Optional.of(zkCredentialKeyPair.getPublicKey()));
when(account.getZkCredentialKeyRotationId()).thenReturn(rotationId);
final AvatarUploadCredentialRequestContext avatarUploadCredentialRequestContext = AvatarUploadCredentialRequestContext.create(
new ServiceId.Aci(AUTHENTICATED_ACI), zkCredentialKeyPair, incorrectRotationId);
final AvatarUploadCredentialRequest credentialRequest = avatarUploadCredentialRequestContext.getRequest();
final StatusRuntimeException statusRuntimeException = assertStatusInvalidArgument(
() -> authenticatedServiceStub().getAvatarCredentials(
GetAvatarCredentialsRequest.newBuilder()
.setAvatarCredentialsRequest(ByteString.copyFrom(credentialRequest.serialize()))
.build()));
assertEquals("invalid credential request", statusRuntimeException.getStatus().getDescription());
@Override
protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {
return serverInterceptors.stream()
// For now, don't validate error conformance because the profiles gRPC service has not been converted to the
// updated error model
.filter(interceptor -> !(interceptor instanceof ErrorConformanceInterceptor))
.toList();
}
@ParameterizedTest

View File

@ -97,7 +97,6 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumen
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidIdempotencyKeyException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPrice;
@ -595,7 +594,7 @@ public class SubscriptionsGrpcServiceTest extends
final byte[] responseBytes = TestRandomUtil.nextBytes(16);
when(receiptCredentialResponse.serialize()).thenReturn(responseBytes);
when(subscriptionManager.createReceiptCredentials(any(), any(), any()))
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, new SubscriptionPaymentProcessor.ReceiptItem("test-item-id", null, 5), PaymentProvider.STRIPE));
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, null, PaymentProvider.STRIPE));
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
GetReceiptCredentialsRequest.newBuilder()
.setSubscriberId(SUBSCRIBER_ID)

View File

@ -23,7 +23,6 @@ import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil.assertFailsWithCause;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@ -50,6 +49,7 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@ -59,7 +59,6 @@ import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.signal.libsignal.zkgroup.ZkCredentialKeyPair;
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@ -472,7 +471,7 @@ class AccountsTest {
}
@Test
void testReclaimAccountPreservesFields() throws Exception {
void testReclaimAccountPreservesFields() {
final String e164 = "+14151112222";
final UUID existingUuid = UUID.randomUUID();
final Account existingAccount =
@ -480,7 +479,7 @@ class AccountsTest {
// the backup credential request and share-set are always preserved across account reclaims
existingAccount.setBackupCredentialRequests(TestRandomUtil.nextBytes(32), TestRandomUtil.nextBytes(32));
existingAccount.setZkCredentialKey(ZkCredentialKeyPair.generate().getPublicKey());
existingAccount.setZkCredentialKey(TestRandomUtil.nextBytes(32));
createAccount(existingAccount);
final Account secondAccount =
generateAccount(e164, UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));
@ -492,11 +491,11 @@ class AccountsTest {
.isEqualTo(existingAccount.getBackupCredentialRequest(BackupCredentialType.MESSAGES).orElseThrow());
assertThat(reclaimed.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElseThrow())
.isEqualTo(existingAccount.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElseThrow());
assertThat(reclaimed.getZkCredentialKey()).hasValue(existingAccount.getZkCredentialKey().orElseThrow());
assertThat(reclaimed.getZkCredentialKey()).isEqualTo(existingAccount.getZkCredentialKey());
}
@Test
void testReclaimAccount() throws Exception {
void testReclaimAccount() throws UsernameHashNotAvailableException {
final String e164 = "+14151112222";
final Device device = generateDevice(DEVICE_ID_1);
final UUID existingUuid = UUID.randomUUID();
@ -507,7 +506,7 @@ class AccountsTest {
final Account.BackupVoucher bv = new Account.BackupVoucher(1, Instant.now().plus(Duration.ofDays(1)));
existingAccount.setBackupVoucher(bv);
// ZK credential keys should be carried over across re-registration
existingAccount.setZkCredentialKey(ZkCredentialKeyPair.generate().getPublicKey());
existingAccount.setZkCredentialKey(TestRandomUtil.nextBytes(32));
createAccount(existingAccount);

View File

@ -166,7 +166,7 @@ public class ChangeNumberManagerTest {
changeNumberManager.changeNumber(accountIdentifier, null, null, null, targetNumber, pniIdentityKey, ecSignedPreKeys, kemLastResortPreKeys, Collections.emptyList(), Collections.emptyMap(), mock(ContainerRequestContext.class));
verify(accountsManager).changeNumber(accountIdentifier, targetNumber, pniIdentityKey, ecSignedPreKeys, kemLastResortPreKeys, Collections.emptyMap());
verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());
verify(messageSender, never()).sendMessages(eq(account), any(), any(), any(), any(), any());
}
@Test

View File

@ -275,15 +275,6 @@ public final class DynamoDbExtensionSchema {
.build()),
List.of(), List.of()),
PROFILE_AVATARS("profileAvatars_test",
ProfileAvatars.KEY_IDENTITY,
null,
List.of(AttributeDefinition.builder()
.attributeName(ProfileAvatars.KEY_IDENTITY)
.attributeType(ScalarAttributeType.B)
.build()),
List.of(), List.of()),
PROFILES_V2("profilesV2_test",
ProfilesV2.KEY_ACCOUNT_UUID,
ProfilesV2.KEY_VERSION,

View File

@ -9,7 +9,7 @@ import com.apple.foundationdb.Database;
import com.apple.foundationdb.FDB;
import java.io.IOException;
public interface FoundationDbDatabaseLifecycleManager {
interface FoundationDbDatabaseLifecycleManager {
void initializeDatabase(final FDB fdb) throws IOException;

View File

@ -1,156 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
class ProfileAvatarsTest {
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS);
private ProfileAvatars profileAvatars;
private static final Duration EXPIRATION = Duration.ofDays(1);
private static final TestClock CLOCK = TestClock.pinned(Instant.now().truncatedTo(ChronoUnit.SECONDS));
@BeforeEach
void setup() {
profileAvatars = new ProfileAvatars(DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName(), EXPIRATION, CLOCK);
}
@Test
void testSet() {
final byte[] identity = TestRandomUtil.nextBytes(32);
final String avatarUrl1 = "avatar";
{
final Optional<String> previousAvatar = profileAvatars.setAvatarUrl(identity, avatarUrl1);
assertTrue(previousAvatar.isEmpty());
}
{
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName())
.key(Map.of(ProfileAvatars.KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.build());
assertTrue(response.hasItem());
assertArrayEquals(identity, response.item().get(ProfileAvatars.KEY_IDENTITY).b().asByteArray());
assertEquals(avatarUrl1, response.item().get(ProfileAvatars.ATTR_URL).s());
assertEquals(CLOCK.instant().plus(EXPIRATION),
Instant.ofEpochSecond(Long.parseLong(response.item().get(ProfileAvatars.ATTR_TTL).n())));
}
final String avatarUrl2 = "avatar2";
{
final Optional<String> previousAvatar = profileAvatars.setAvatarUrl(identity, avatarUrl2);
assertEquals(Optional.of(avatarUrl1), previousAvatar);
}
{
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName())
.key(Map.of(ProfileAvatars.KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.build());
assertTrue(response.hasItem());
assertArrayEquals(identity, response.item().get(ProfileAvatars.KEY_IDENTITY).b().asByteArray());
assertEquals(avatarUrl2, response.item().get(ProfileAvatars.ATTR_URL).s());
}
}
@Test
void testUpdateTtl() {
final byte[] identity = TestRandomUtil.nextBytes(32);
final String avatarUrl = "avatar";
profileAvatars.setAvatarUrl(identity, avatarUrl);
{
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName())
.key(Map.of(ProfileAvatars.KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.build());
assertTrue(response.hasItem());
assertArrayEquals(identity, response.item().get(ProfileAvatars.KEY_IDENTITY).b().asByteArray());
assertEquals(CLOCK.instant().plus(EXPIRATION),
Instant.ofEpochSecond(Long.parseLong(response.item().get(ProfileAvatars.ATTR_TTL).n())));
}
final Instant later = CLOCK.instant().plus(Duration.ofDays(30));
CLOCK.pin(later);
profileAvatars.updateAvatarTtl(identity);
{
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName())
.key(Map.of(ProfileAvatars.KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.build());
assertTrue(response.hasItem());
assertArrayEquals(identity, response.item().get(ProfileAvatars.KEY_IDENTITY).b().asByteArray());
assertEquals(later.plus(EXPIRATION),
Instant.ofEpochSecond(Long.parseLong(response.item().get(ProfileAvatars.ATTR_TTL).n())));
}
}
@Test
void testUpdateTtlNotPresent() {
final byte[] identity = TestRandomUtil.nextBytes(32);
assertTrue(profileAvatars.updateAvatarTtl(identity).isEmpty());
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.PROFILE_AVATARS.tableName())
.key(Map.of(ProfileAvatars.KEY_IDENTITY, AttributeValues.fromByteArray(identity)))
.build());
assertFalse(response.hasItem(), "item should not be created as a side effect");
}
@Test
void testDelete() {
final byte[] identity = TestRandomUtil.nextBytes(32);
assertTrue(profileAvatars.deleteAvatarUrl(identity).isEmpty());
final String avatarUrl = "avatar";
profileAvatars.setAvatarUrl(identity, avatarUrl);
final Optional<String> deletedUrl = profileAvatars.deleteAvatarUrl(identity);
assertTrue(deletedUrl.isPresent());
assertEquals(avatarUrl, deletedUrl.get());
assertTrue(profileAvatars.deleteAvatarUrl(identity).isEmpty());
}
}

View File

@ -11,12 +11,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@ -44,7 +42,6 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
@ -59,18 +56,13 @@ import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import software.amazon.awssdk.services.dynamodb.model.DynamoDbException;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.CopyObjectResponse;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.S3Exception;
@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
public class ProfilesManagerTest {
private Profiles profilesV1;
private ProfilesV2 profilesV2;
private ProfileAvatars profileAvatars;
private RedisAdvancedClusterCommands<byte[], byte[]> commands;
private RedisAdvancedClusterAsyncCommands<byte[], byte[]> asyncCommands;
private S3AsyncClient s3Client;
@ -93,11 +85,10 @@ public class ProfilesManagerTest {
profilesV1 = mock(Profiles.class);
profilesV2 = mock(ProfilesV2.class);
profileAvatars = mock(ProfileAvatars.class);
s3Client = mock(S3AsyncClient.class);
setLuaScript = mock(ClusterLuaScript.class);
profilesManager = new ProfilesManager(profilesV1, profilesV2, profileAvatars, cacheCluster, mock(ScheduledExecutorService.class),
profilesManager = new ProfilesManager(profilesV1, profilesV2, cacheCluster, mock(ScheduledExecutorService.class),
s3Client, BUCKET, setLuaScript);
}
@ -458,104 +449,6 @@ public class ProfilesManagerTest {
);
}
@Test
void setAvatarForIdentity() {
final byte[] identity = new byte[1];
final String url1 = "url1";
when(profileAvatars.setAvatarUrl(any(byte[].class), anyString()))
.thenReturn(Optional.empty());
profilesManager.setAvatarForIdentity(identity, url1);
verify(profileAvatars).setAvatarUrl(identity, url1);
verifyNoInteractions(s3Client);
when(profileAvatars.setAvatarUrl(any(byte[].class), anyString()))
.thenReturn(Optional.of(url1));
final String url2 = "url2";
profilesManager.setAvatarForIdentity(identity, url2);
verify(profileAvatars).setAvatarUrl(identity, url2);
verify(s3Client).deleteObject(argThat((DeleteObjectRequest r) -> url1.equals(r.key())));
}
@Test
void extendAvatarTtlForIdentity() {
final String avatarPath = "somePath";
when(profileAvatars.updateAvatarTtl(any(byte[].class))).thenReturn(Optional.of(avatarPath));
assertEquals(Optional.of(avatarPath), profilesManager.extendAvatarTtlForIdentity(new byte[1]));
final ArgumentCaptor<CopyObjectRequest> copyObjectRequestArgumentCaptor = ArgumentCaptor.forClass(CopyObjectRequest.class);
verify(s3Client).copyObject(copyObjectRequestArgumentCaptor.capture());
assertEquals(avatarPath, copyObjectRequestArgumentCaptor.getValue().sourceKey());
assertEquals(avatarPath, copyObjectRequestArgumentCaptor.getValue().destinationKey());
}
@Test
void extendAvatarTtlForIdentityNotFound() {
when(profileAvatars.updateAvatarTtl(any(byte[].class))).thenReturn(Optional.empty());
assertTrue(profilesManager.extendAvatarTtlForIdentity(new byte[1]).isEmpty());
verifyNoInteractions(s3Client);
}
@Test
void extendAvatarTtlForIdentityS3NoSuchKey() {
final String avatarPath = "somePath";
when(profileAvatars.updateAvatarTtl(any(byte[].class))).thenReturn(Optional.of(avatarPath));
when(s3Client.copyObject(any(CopyObjectRequest.class)))
.thenReturn(CompletableFuture.failedFuture(NoSuchKeyException.builder().build()));
assertTrue(profilesManager.extendAvatarTtlForIdentity(new byte[1]).isEmpty());
verify(s3Client).copyObject(any(CopyObjectRequest.class));
verify(profileAvatars).deleteAvatarUrl(any(byte[].class));
}
@Test
void extendAvatarTtlForIdentityS3Retry() {
final String avatarPath = "somePath";
when(profileAvatars.updateAvatarTtl(any(byte[].class))).thenReturn(Optional.of(avatarPath));
when(s3Client.copyObject(any(CopyObjectRequest.class)))
.thenReturn(CompletableFuture.failedFuture(S3Exception.builder().build()))
.thenReturn(CompletableFuture.completedFuture(mock(CopyObjectResponse.class)));
assertEquals(Optional.of(avatarPath), profilesManager.extendAvatarTtlForIdentity(new byte[1]));
verify(s3Client, times(2)).copyObject(any(CopyObjectRequest.class));
verify(profileAvatars, never()).deleteAvatarUrl(any(byte[].class));
}
@Test
void deleteAvatarForIdentity() {
final String avatarPath = "somePath";
when(profileAvatars.deleteAvatarUrl(any(byte[].class))).thenReturn(Optional.of(avatarPath));
profilesManager.deleteAvatarForIdentity(new byte[1]);
final ArgumentCaptor<DeleteObjectRequest> deleteObjectRequestArgumentCaptor = ArgumentCaptor.forClass(
DeleteObjectRequest.class);
verify(s3Client).deleteObject(deleteObjectRequestArgumentCaptor.capture());
assertEquals(avatarPath, deleteObjectRequestArgumentCaptor.getValue().key());
}
@Test
void deleteAvatarForIdentityNotFound() {
when(profileAvatars.deleteAvatarUrl(any(byte[].class))).thenReturn(Optional.empty());
profilesManager.deleteAvatarForIdentity(new byte[1]);
verifyNoInteractions(s3Client);
}
@Nested
class Redis {
@ -564,7 +457,7 @@ public class ProfilesManagerTest {
@BeforeEach
void setUp() throws Exception {
profilesManager = new ProfilesManager(profilesV1, profilesV2, profileAvatars, REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class),
profilesManager = new ProfilesManager(profilesV1, profilesV2, REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class),
s3Client, BUCKET);
}

View File

@ -16,7 +16,7 @@ import org.slf4j.LoggerFactory;
/**
* Manages the lifecycle of a database connected to a FoundationDB instance running as an external service container.
*/
public class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
private final String foundationDbServiceContainerName;
@ -24,7 +24,7 @@ public class ServiceContainerFoundationDbDatabaseLifecycleManager implements Fou
private static final Logger log = LoggerFactory.getLogger(ServiceContainerFoundationDbDatabaseLifecycleManager.class);
public ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
log.info("Using FoundationDB service container: {}", foundationDbServiceContainerName);
this.foundationDbServiceContainerName = foundationDbServiceContainerName;
}

View File

@ -6,7 +6,6 @@ package org.whispersystems.textsecuregcm.subscriptions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
@ -64,19 +63,6 @@ class AppleAppStoreClientTest {
verifyNoInteractions(sandboxClient);
}
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"TRANSACTION_ID_NOT_FOUND", "ORIGINAL_TRANSACTION_ID_NOT_FOUND", "ACCOUNT_NOT_FOUND"})
public void notFound(APIError error) throws APIException, IOException {
when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
.thenThrow(new APIException(404, error, "test"));
when(sandboxClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
.thenThrow(new APIException(404, error, "test"));
assertThatExceptionOfType(SubscriptionNotFoundException.class)
.isThrownBy(() -> apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty()));
}
@Test
public void fallbackOnNoTransactionFound()
throws APIException, IOException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException {

View File

@ -158,8 +158,6 @@ zkConfig-libsignal-0.42.serverSecret: ALxy0qUiV9B3OgF1GzTgn6g4NSN22ww87p5xOlYkyp
genericZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==
callingZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==
backupsZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==
profileAvatarsZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==
paymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
paymentsService.fixerApiKey: unset
paymentsService.coinGeckoApiKey: unset

View File

@ -134,8 +134,6 @@ dynamoDbTables:
expiration: P90D
phoneNumberIdentifiers:
tableName: pni_test
profileAvatars:
tableName: profileAvatars_test
profiles:
tableName: profiles_test
profilesV2:
@ -337,9 +335,6 @@ callingZkConfig:
backupsZkConfig:
serverSecret: secret://backupsZkConfig.serverSecret
profileAvatarsZkConfig:
serverSecret: secret://profileAvatarsZkConfig.serverSecret
dynamicConfig:
type: static
object: |