Compare commits
No commits in common. "main" and "v20260624.1.0" have entirely different histories.
main
...
v20260624.
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<>();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(() ->
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
|
||||
@ -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"];
|
||||
}
|
||||
}
|
||||
@ -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 request’s avatar auth credential
|
||||
rpc ExtendAvatarTTL(ExtendAvatarTTLRequest) returns (ExtendAvatarTTLResponse) {}
|
||||
|
||||
// Deletes the avatar currently associated with the request’s 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"];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user