Add POST /v1/donation/permit
This commit is contained in:
parent
911feceacb
commit
4a3275ad63
2
pom.xml
2
pom.xml
@ -287,7 +287,7 @@
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
<version>0.86.6</version>
|
||||
<version>0.96.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
|
||||
@ -1222,7 +1222,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDeviceCheck().backupRedemptionDuration()),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new),
|
||||
ReceiptCredentialPresentation::new, zkSecretParams, rateLimiters),
|
||||
new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()),
|
||||
new KeyTransparencyController(keyTransparencyServiceClient),
|
||||
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, accountsManager,
|
||||
|
||||
@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
@ -21,16 +22,22 @@ import jakarta.ws.rs.core.Response.Status;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.glassfish.jersey.server.ManagedAsync;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitDerivedKeyPair;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
|
||||
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.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitsRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
@ -46,20 +53,26 @@ public class DonationController {
|
||||
private final AccountsManager accountsManager;
|
||||
private final BadgesConfiguration badgesConfiguration;
|
||||
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
private final ServerSecretParams serverSecretParams;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public DonationController(
|
||||
@Nonnull final Clock clock,
|
||||
@Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
|
||||
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
@Nonnull final AccountsManager accountsManager,
|
||||
@Nonnull final BadgesConfiguration badgesConfiguration,
|
||||
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
|
||||
final Clock clock,
|
||||
final ServerZkReceiptOperations serverZkReceiptOperations,
|
||||
final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
final AccountsManager accountsManager,
|
||||
final BadgesConfiguration badgesConfiguration,
|
||||
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
|
||||
final ServerSecretParams serverSecretParams,
|
||||
final RateLimiters rateLimiters) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
|
||||
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
|
||||
this.accountsManager = Objects.requireNonNull(accountsManager);
|
||||
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
|
||||
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
|
||||
this.serverSecretParams = Objects.requireNonNull(serverSecretParams);
|
||||
this.rateLimiters = Objects.requireNonNull(rateLimiters);
|
||||
}
|
||||
|
||||
@POST
|
||||
@ -131,4 +144,37 @@ public class DonationController {
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/permit")
|
||||
@Produces({MediaType.APPLICATION_JSON})
|
||||
@Operation(
|
||||
summary = "Generate permits for anonymous donation endpoints",
|
||||
description = """
|
||||
Generate a set of anonymous, single-use, permits for use with /v1/subscription endpoints.
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "400", description = "Invalid credential request")
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; reduce requested permit count and/or try again after the prescribed delay")
|
||||
public CreateDonationPermitResponse createPermits(@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final CreateDonationPermitsRequest request) throws RateLimitExceededException {
|
||||
|
||||
final DonationPermitRequest permitRequest;
|
||||
try {
|
||||
permitRequest = new DonationPermitRequest(request.permitRequest());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
rateLimiters.getCreateDonationPermitLimiter().validate(auth.accountIdentifier(), permitRequest.getPermitCount());
|
||||
|
||||
final DonationPermitDerivedKeyPair derivedKeyPair = DonationPermitDerivedKeyPair.forExpiration(
|
||||
DonationPermitResponse.defaultExpiration(clock.instant()), serverSecretParams);
|
||||
|
||||
final DonationPermitResponse permitResponse = permitRequest.issue(derivedKeyPair);
|
||||
|
||||
return new CreateDonationPermitResponse(permitResponse.serialize());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateDonationPermitResponse(
|
||||
@Schema(description = "A serialized DonationPermitResponse")
|
||||
@NotEmpty
|
||||
@NotNull
|
||||
@Valid
|
||||
byte[] permitResponse) {
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateDonationPermitsRequest(
|
||||
@Schema(description = "A serialized DonationPermitRequest")
|
||||
@NotEmpty
|
||||
@NotNull
|
||||
byte[] permitRequest) {
|
||||
}
|
||||
@ -61,6 +61,7 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
|
||||
DEVICE_CHECK_CHALLENGE("deviceCheckChallenge", new RateLimiterConfig(10, Duration.ofMinutes(1), false)),
|
||||
SUBMIT_CALL_QUALITY_SURVEY("submitCallQualitySurvey", new RateLimiterConfig(100, Duration.ofMinutes(1), true)),
|
||||
BATCH_IDENTITY_CHECK("batchIdentityCheck", new RateLimiterConfig(100, Duration.ofMinutes(1), true)),
|
||||
CREATE_DONATION_PERMIT("createDonationCredential", new RateLimiterConfig(30, Duration.ofHours(4), true)),
|
||||
ONE_TIME_DONATION("oneTimeDonation", new RateLimiterConfig(5, Duration.ofMinutes(1), true)),
|
||||
ADD_SUBSCRIPTION_PAYMENT_METHOD("addSubscriptionPaymentMethod", new RateLimiterConfig(10, Duration.ofMinutes(1), true)),
|
||||
;
|
||||
@ -241,7 +242,7 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
|
||||
return forDescriptor(For.SUBMIT_CALL_QUALITY_SURVEY);
|
||||
}
|
||||
|
||||
public RateLimiter getBatchIdentityCheckLimiter() {
|
||||
return forDescriptor(For.BATCH_IDENTITY_CHECK);
|
||||
public RateLimiter getCreateDonationPermitLimiter() {
|
||||
return forDescriptor(For.CREATE_DONATION_PERMIT);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,12 @@
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@ -18,16 +21,21 @@ import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import jakarta.ws.rs.client.Entity;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermit;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
@ -35,7 +43,12 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitsRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
@ -64,20 +77,20 @@ class DonationControllerTest {
|
||||
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3"));
|
||||
}
|
||||
|
||||
final Clock clock = TestClock.pinned(Instant.ofEpochSecond(nowEpochSeconds));
|
||||
ServerZkReceiptOperations zkReceiptOperations;
|
||||
RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
AccountsManager accountsManager;
|
||||
byte[] receiptSerialBytes;
|
||||
ReceiptSerial receiptSerial;
|
||||
byte[] presentation;
|
||||
ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
ResourceExtension resources;
|
||||
private final TestClock clock = TestClock.pinned(Instant.ofEpochSecond(nowEpochSeconds));
|
||||
private final ServerSecretParams serverSecretParams = ServerSecretParams.generate();
|
||||
|
||||
private RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
private AccountsManager accountsManager;
|
||||
private ReceiptSerial receiptSerial;
|
||||
private byte[] presentation;
|
||||
private ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
private ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
private RateLimiter createDonationPermitLimiter;
|
||||
private ResourceExtension resources;
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() throws Throwable {
|
||||
zkReceiptOperations = mock(ServerZkReceiptOperations.class);
|
||||
redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);
|
||||
accountsManager = mock(AccountsManager.class);
|
||||
AccountsHelper.setupMockUpdate(accountsManager);
|
||||
@ -86,6 +99,12 @@ class DonationControllerTest {
|
||||
receiptCredentialPresentationFactory = mock(ReceiptCredentialPresentationFactory.class);
|
||||
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
|
||||
|
||||
final RateLimiters rateLimiters = mock(RateLimiters.class);
|
||||
|
||||
createDonationPermitLimiter = mock(RateLimiter.class);
|
||||
when(rateLimiters.getCreateDonationPermitLimiter())
|
||||
.thenReturn(createDonationPermitLimiter);
|
||||
|
||||
try {
|
||||
when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation);
|
||||
} catch (InvalidInputException e) {
|
||||
@ -95,9 +114,10 @@ class DonationControllerTest {
|
||||
resources = ResourceExtension.builder()
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
|
||||
.addProvider(RateLimitExceededExceptionMapper.class)
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,
|
||||
getBadgesConfiguration(), receiptCredentialPresentationFactory))
|
||||
.addResource(new DonationController(clock, mock(ServerZkReceiptOperations.class), redeemedReceiptsManager, accountsManager,
|
||||
getBadgesConfiguration(), receiptCredentialPresentationFactory, serverSecretParams, rateLimiters))
|
||||
.build();
|
||||
resources.before();
|
||||
}
|
||||
@ -119,13 +139,15 @@ class DonationControllerTest {
|
||||
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
|
||||
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
|
||||
Response response = resources.getJerseyTest()
|
||||
try (Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/redeem-receipt")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge("TEST1", Instant.ofEpochSecond(receiptExpiration), true)));
|
||||
verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq("TEST1"));
|
||||
}
|
||||
@ -142,26 +164,75 @@ class DonationControllerTest {
|
||||
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
|
||||
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
|
||||
Response response = resources.getJerseyTest()
|
||||
try (Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/redeem-receipt")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed");
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRedeemReceiptBadCredentialPresentation() throws InvalidInputException {
|
||||
when(receiptCredentialPresentationFactory.build(any())).thenThrow(new InvalidInputException());
|
||||
|
||||
final Response response = resources.getJerseyTest()
|
||||
try(Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/redeem-receipt")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(new RedeemReceiptRequest(presentation, true, true), MediaType.APPLICATION_JSON_TYPE));
|
||||
.post(Entity.entity(new RedeemReceiptRequest(presentation, true, true), MediaType.APPLICATION_JSON_TYPE))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePermits() {
|
||||
|
||||
final int permitCount = 10;
|
||||
final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(permitCount);
|
||||
final CreateDonationPermitsRequest request = new CreateDonationPermitsRequest(context.request().serialize());
|
||||
|
||||
try (Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/permit")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
|
||||
final DonationPermitResponse donationPermitResponse = assertDoesNotThrow(() -> new DonationPermitResponse(
|
||||
response.readEntity(CreateDonationPermitResponse.class).permitResponse()));
|
||||
|
||||
final List<DonationPermit> permits = assertDoesNotThrow(() -> context.receive(donationPermitResponse, serverSecretParams.getPublicParams(), clock.instant()));
|
||||
|
||||
assertThat(permits.size()).isEqualTo(permitCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePermitsRateLimited() throws Exception {
|
||||
|
||||
final int permitCount = 10;
|
||||
final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(permitCount);
|
||||
final CreateDonationPermitsRequest request = new CreateDonationPermitsRequest(context.request().serialize());
|
||||
|
||||
final Duration retryDuration = Duration.ofHours(1);
|
||||
doThrow(new RateLimitExceededException(retryDuration))
|
||||
.when(createDonationPermitLimiter).validate(any(UUID.class), anyLong());
|
||||
|
||||
try (Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/permit")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
assertThat(response.getHeaderString("Retry-After")).asInt().isEqualTo(retryDuration.toSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user