mirror of
https://github.com/signalapp/Signal-Server
synced 2026-07-05 02:55:03 +01:00
Port DonationController to gRPC
This commit is contained in:
committed by
Jon Chambers
parent
dd93833324
commit
32befd7c9a
@@ -156,6 +156,7 @@ import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.CredentialsAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.CredentialsGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.DevicesGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.DonationsGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.ExternalServiceDefinitions;
|
||||
@@ -1004,7 +1005,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters,
|
||||
gcsAttachmentGenerator, tusAttachmentGenerator, config.getAttachments().maxAttachmentUploadSizeInBytes()),
|
||||
new PaymentsGrpcService(currencyManager),
|
||||
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker))
|
||||
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
|
||||
new DonationsGrpcService(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), ReceiptCredentialPresentation::new))
|
||||
.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!
|
||||
|
||||
+1
-4
@@ -34,15 +34,12 @@ import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
|
||||
|
||||
@Path("/v1/donation")
|
||||
@Tag(name = "Donations")
|
||||
public class DonationController {
|
||||
|
||||
public interface ReceiptCredentialPresentationFactory {
|
||||
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
private final ServerZkReceiptOperations serverZkReceiptOperations;
|
||||
private final RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.Empty;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import org.signal.chat.donations.RedeemReceiptRequest;
|
||||
import org.signal.chat.donations.RedeemReceiptResponse;
|
||||
import org.signal.chat.donations.SimpleDonationsGrpc;
|
||||
import org.signal.chat.errors.FailedPrecondition;
|
||||
import org.signal.chat.errors.FailedZkAuthentication;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
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.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
|
||||
|
||||
public class DonationsGrpcService extends SimpleDonationsGrpc.DonationsImplBase {
|
||||
|
||||
private final Clock clock;
|
||||
private final ServerZkReceiptOperations serverZkReceiptOperations;
|
||||
private final RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
private final AccountsManager accountsManager;
|
||||
private final BadgesConfiguration badgesConfiguration;
|
||||
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(DonationsGrpcService.class);
|
||||
|
||||
public DonationsGrpcService(
|
||||
final Clock clock,
|
||||
final ServerZkReceiptOperations serverZkReceiptOperations,
|
||||
final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
final AccountsManager accountsManager,
|
||||
final BadgesConfiguration badgesConfiguration,
|
||||
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
|
||||
|
||||
this.clock = clock;
|
||||
this.serverZkReceiptOperations = serverZkReceiptOperations;
|
||||
this.redeemedReceiptsManager = redeemedReceiptsManager;
|
||||
this.accountsManager = accountsManager;
|
||||
this.badgesConfiguration = badgesConfiguration;
|
||||
this.receiptCredentialPresentationFactory = receiptCredentialPresentationFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedeemReceiptResponse redeemReceipt(final RedeemReceiptRequest request) {
|
||||
try {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
final ReceiptCredentialPresentation receiptCredentialPresentation = receiptCredentialPresentationFactory
|
||||
.build(request.getReceiptCredentialPresentation().toByteArray());
|
||||
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
|
||||
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
|
||||
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
|
||||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
|
||||
if (badgeId == null) {
|
||||
// Since the receipt presentation checked out, the server messed up because it doesn't recognize a receipt level it previously issued.
|
||||
LOGGER.error("Server doesn't recognize previously issued receipt level; please check badgesConfiguration for issues");
|
||||
throw GrpcExceptions.unavailable("server does not recognize the requested receipt level");
|
||||
}
|
||||
final boolean receiptMatched = redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, authenticatedDevice.accountIdentifier());
|
||||
if (!receiptMatched) {
|
||||
return RedeemReceiptResponse.newBuilder()
|
||||
.setAlreadyRedeemed(FailedPrecondition.newBuilder()
|
||||
.setDescription("receipt has already been redeemed")
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
accountsManager.update(authenticatedDevice.accountIdentifier(), a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.getVisible()));
|
||||
if (request.getPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
}
|
||||
});
|
||||
|
||||
return RedeemReceiptResponse.newBuilder()
|
||||
.setSuccess(Empty.getDefaultInstance())
|
||||
.build();
|
||||
|
||||
} catch (final InvalidInputException e) {
|
||||
return RedeemReceiptResponse.newBuilder()
|
||||
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
|
||||
.setDescription("invalid receipt credential presentation")
|
||||
.build())
|
||||
.build();
|
||||
} catch (final VerificationFailedException e) {
|
||||
return RedeemReceiptResponse.newBuilder()
|
||||
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
|
||||
.setDescription("receipt credential presentation verification failed")
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
|
||||
public interface ReceiptCredentialPresentationFactory {
|
||||
|
||||
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.donations;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "org/signal/chat/require.proto";
|
||||
import "org/signal/chat/errors.proto";
|
||||
import "org/signal/chat/tag.proto";
|
||||
|
||||
service Donations {
|
||||
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
|
||||
// Redeem a receipt acquired from Subscriptions.CreateSubscriptionReceiptCredentials
|
||||
// to add a badge to the account. After successful redemption, profile
|
||||
// responses will include the corresponding badge (if configured as visible)
|
||||
// until the expiration time on the receipt.
|
||||
rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}
|
||||
}
|
||||
|
||||
message RedeemReceiptRequest {
|
||||
// Presentation of the ZK receipt acquired when the subscription was created
|
||||
bytes receiptCredentialPresentation = 1 [(require.exactlySize) = 329];
|
||||
// If true, the corresponding badge should be visible on the profile
|
||||
bool visible = 2;
|
||||
// If true, and the new badge is visible, it should be the primary badge on the profile
|
||||
bool primary = 3;
|
||||
}
|
||||
|
||||
message RedeemReceiptResponse {
|
||||
oneof response {
|
||||
// The receipt was successfully redeemed
|
||||
google.protobuf.Empty success = 1;
|
||||
// The provided presentation is invalid
|
||||
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
|
||||
// The receipt was already redeemed for a different account
|
||||
errors.FailedPrecondition already_redeemed = 3 [(tag.reason) = "already_redeemed"];
|
||||
}
|
||||
|
||||
}
|
||||
+3
-2
@@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
@@ -70,7 +71,7 @@ class DonationControllerTest {
|
||||
byte[] receiptSerialBytes;
|
||||
ReceiptSerial receiptSerial;
|
||||
byte[] presentation;
|
||||
DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
ResourceExtension resources;
|
||||
|
||||
@@ -82,7 +83,7 @@ class DonationControllerTest {
|
||||
AccountsHelper.setupMockUpdate(accountsManager);
|
||||
receiptSerial = new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE));
|
||||
presentation = TestRandomUtil.nextBytes(25);
|
||||
receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);
|
||||
receiptCredentialPresentationFactory = mock(ReceiptCredentialPresentationFactory.class);
|
||||
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
|
||||
|
||||
try {
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junitpioneer.jupiter.cartesian.CartesianTest;
|
||||
import org.mockito.Mock;
|
||||
import org.signal.chat.donations.DonationsGrpc;
|
||||
import org.signal.chat.donations.RedeemReceiptRequest;
|
||||
import org.signal.chat.donations.RedeemReceiptResponse;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
|
||||
class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService, DonationsGrpc.DonationsBlockingStub> {
|
||||
|
||||
@Mock
|
||||
private ServerZkReceiptOperations zkReceiptOperations;
|
||||
|
||||
@Mock
|
||||
private RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
|
||||
@Mock
|
||||
private AccountsManager accountsManager;
|
||||
|
||||
@Mock
|
||||
private Account account;
|
||||
|
||||
@Mock
|
||||
private ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
|
||||
@Mock
|
||||
private BadgesConfiguration badgesConfiguration;
|
||||
|
||||
@Mock
|
||||
private ReceiptSerial receiptSerial;
|
||||
|
||||
private final Clock clock = TestClock.pinned(Instant.ofEpochSecond(100));
|
||||
|
||||
private static final long EXPIRATION_TIME_EPOCH_SECONDS = 200;
|
||||
|
||||
@Override
|
||||
protected DonationsGrpcService createServiceBeforeEachTest() {
|
||||
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(account));
|
||||
AccountsHelper.setupMockUpdate(accountsManager);
|
||||
final ReceiptCredentialPresentation receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
|
||||
try {
|
||||
when(receiptCredentialPresentationFactory.build(any())).thenReturn(receiptCredentialPresentation);
|
||||
} catch (final InvalidInputException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(1L);
|
||||
when(badgesConfiguration.getReceiptLevels()).thenReturn(Map.of(1L, "testBadge"));
|
||||
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(EXPIRATION_TIME_EPOCH_SECONDS);
|
||||
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
|
||||
return new DonationsGrpcService(
|
||||
clock,
|
||||
zkReceiptOperations,
|
||||
redeemedReceiptsManager,
|
||||
accountsManager,
|
||||
badgesConfiguration,
|
||||
receiptCredentialPresentationFactory
|
||||
);
|
||||
}
|
||||
|
||||
@CartesianTest
|
||||
void redeemReceipt(
|
||||
@CartesianTest.Values(booleans = {true, false}) final boolean isVisible,
|
||||
@CartesianTest.Values(booleans = {true, false}) final boolean isPrimary) {
|
||||
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
|
||||
true);
|
||||
|
||||
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
|
||||
.setVisible(isVisible)
|
||||
.setPrimary(isPrimary)
|
||||
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
|
||||
.build());
|
||||
|
||||
assertEquals(RedeemReceiptResponse.ResponseCase.SUCCESS, response.getResponseCase());
|
||||
|
||||
verify(account).addBadge(clock,
|
||||
new AccountBadge("testBadge", Instant.ofEpochSecond(EXPIRATION_TIME_EPOCH_SECONDS), isVisible));
|
||||
if (isPrimary) {
|
||||
verify(account).makeBadgePrimaryIfExists(clock, "testBadge");
|
||||
} else {
|
||||
verify(account, never()).makeBadgePrimaryIfExists(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyRedeemed() {
|
||||
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
|
||||
false);
|
||||
|
||||
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
|
||||
.setVisible(true)
|
||||
.setPrimary(true)
|
||||
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
|
||||
.build());
|
||||
|
||||
assertEquals(RedeemReceiptResponse.ResponseCase.ALREADY_REDEEMED, response.getResponseCase());
|
||||
verifyNoInteractions(account);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"true,false", "false, true"})
|
||||
void failedZkAuthentication(final boolean invalidPresentation, final boolean verificationFailed)
|
||||
throws InvalidInputException, VerificationFailedException {
|
||||
if (invalidPresentation) {
|
||||
doThrow(InvalidInputException.class).when(receiptCredentialPresentationFactory).build(any());
|
||||
}
|
||||
if (verificationFailed) {
|
||||
doThrow(VerificationFailedException.class).when(zkReceiptOperations).verifyReceiptCredentialPresentation(any());
|
||||
}
|
||||
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
|
||||
.setVisible(true)
|
||||
.setPrimary(true)
|
||||
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
|
||||
.build());
|
||||
assertEquals(RedeemReceiptResponse.ResponseCase.FAILED_AUTHENTICATION, response.getResponseCase());
|
||||
verifyNoInteractions(account);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user