From 32befd7c9ae5e8d536faeb06392ca4bc87ccd6f2 Mon Sep 17 00:00:00 2001 From: Ameya Lokare Date: Wed, 3 Jun 2026 09:50:17 -0700 Subject: [PATCH] Port DonationController to gRPC --- .../textsecuregcm/WhisperServerService.java | 4 +- .../controllers/DonationController.java | 5 +- .../grpc/DonationsGrpcService.java | 104 ++++++++++++ .../ReceiptCredentialPresentationFactory.java | 9 ++ .../proto/org/signal/chat/donations.proto | 45 ++++++ .../controllers/DonationControllerTest.java | 5 +- .../grpc/DonationsGrpcServiceTest.java | 149 ++++++++++++++++++ 7 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcService.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ReceiptCredentialPresentationFactory.java create mode 100644 service/src/main/proto/org/signal/chat/donations.proto create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcServiceTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 44c8ce21c..116d7e8a5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 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! diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java index 8b31533b3..8c2fc31eb 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java @@ -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; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcService.java new file mode 100644 index 000000000..126867994 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcService.java @@ -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(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ReceiptCredentialPresentationFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ReceiptCredentialPresentationFactory.java new file mode 100644 index 000000000..6d67a948c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ReceiptCredentialPresentationFactory.java @@ -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; +} diff --git a/service/src/main/proto/org/signal/chat/donations.proto b/service/src/main/proto/org/signal/chat/donations.proto new file mode 100644 index 000000000..9af7fd390 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/donations.proto @@ -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"]; + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java index 818e0e362..e6d662282 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java @@ -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 { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcServiceTest.java new file mode 100644 index 000000000..af09d5276 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DonationsGrpcServiceTest.java @@ -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 { + + @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); + } +}