diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt index 8518746645..45d0edcfd1 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt @@ -37,6 +37,7 @@ class InAppPaymentsRule : ExternalResource() { private fun initialisePutSubscription() { AppDependencies.donationsApi.apply { every { putSubscription(any()) } returns NetworkResult.Success(Unit) + every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index c831ac458d..cbedfbd24c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -174,7 +174,7 @@ object RecurringInAppPaymentRepository { InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate() } - donationsService.putSubscription(subscriberId).resultOrThrow + donationsService.createSubscriber(subscriberId).resultOrThrow Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermits.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermits.kt new file mode 100644 index 0000000000..099f08668b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermits.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.permits + +import androidx.annotation.WorkerThread +import org.signal.core.util.Base64 +import org.signal.donations.permits.DonationPermitError +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RestStatusCodeError +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.whispersystems.signalservice.api.donations.DonationPermitProvider +import java.io.IOException + +/** + * App-side [DonationPermitProvider]: spends a permit and base64-encodes it for the `Donation-Permit` header, + * translating an acquisition failure into a [RequestResult] the donations service can surface. + */ +object DonationPermits : DonationPermitProvider { + + @WorkerThread + override fun getDonationPermit(): RequestResult { + return AppDependencies.donationPermitsRepository + .spendOrAcquirePermit() + .fold( + ifLeft = { it.toRequestResult() }, + ifRight = { RequestResult.Success(Base64.encodeWithPadding(it.serialize())) } + ) + } + + private fun DonationPermitError.toRequestResult(): RequestResult { + return when (this) { + is DonationPermitError.IssuerUnavailable -> { + val statusCode = statusCode + val cause = cause + when { + statusCode != null -> RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null)) + cause is IOException -> RequestResult.RetryableNetworkError(cause) + else -> RequestResult.ApplicationError(cause ?: IllegalStateException("Donation permit issuer unavailable")) + } + } + DonationPermitError.VerificationFailed -> RequestResult.ApplicationError(IllegalStateException("Donation permit verification failed")) + DonationPermitError.MalformedResponse -> RequestResult.ApplicationError(IllegalStateException("Malformed donation permit response")) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuer.kt new file mode 100644 index 0000000000..07a7c39b08 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuer.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.permits + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import org.signal.donations.permits.DonationPermitError +import org.signal.donations.permits.DonationPermitIssuer +import org.signal.libsignal.net.RequestResult +import org.thoughtcrime.securesms.dependencies.AppDependencies + +/** + * [DonationPermitIssuer] backed by [org.whispersystems.signalservice.api.donations.DonationsApi], translating a + * non-success [RequestResult] into a [DonationPermitError]. + */ +object NetworkDonationPermitIssuer : DonationPermitIssuer { + + override fun issue(requestBytes: ByteArray): Either { + return when (val result = AppDependencies.donationsApi.createDonationPermits(requestBytes)) { + is RequestResult.Success -> result.result.right() + is RequestResult.NonSuccess -> DonationPermitError.IssuerUnavailable(statusCode = result.error.statusCode).left() + is RequestResult.RetryableNetworkError -> DonationPermitError.IssuerUnavailable(cause = result.networkError).left() + is RequestResult.ApplicationError -> DonationPermitError.IssuerUnavailable(cause = result.cause).left() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index bccf6f556f..32307e7440 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -14,6 +14,7 @@ import org.signal.core.util.concurrent.LatestValueObservable import org.signal.core.util.contentproviders.BlobProvider import org.signal.core.util.orNull import org.signal.core.util.resettableLazy +import org.signal.donations.permits.DonationPermitsRepository import org.signal.glide.SignalGlideDependencies import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations @@ -415,6 +416,11 @@ object AppDependencies { val donationsApi: DonationsApi get() = networkModule.donationsApi + @JvmStatic + val donationPermitsRepository: DonationPermitsRepository by lazy { + provider.provideDonationPermitsRepository(signalServiceNetworkAccess.getConfiguration().zkGroupServerPublicParams) + } + val keyTransparencyApi: KeyTransparencyApi get() = networkModule.keyTransparencyApi @@ -488,6 +494,7 @@ object AppDependencies { fun provideExoPlayerPool(): ExoPlayerPool fun provideAndroidCallAudioManager(): AudioManagerCompat fun provideDonationsService(donationsApi: DonationsApi): DonationsService + fun provideDonationPermitsRepository(zkGroupServerPublicParams: ByteArray): DonationPermitsRepository fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService fun provideDeadlockDetector(): DeadlockDetector fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations @@ -507,6 +514,7 @@ object AppDependencies { fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi fun providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi + fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi fun provideMessageApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): MessageApi diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 2787792224..a9ad7ab192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -22,10 +22,12 @@ import org.signal.core.util.billing.BillingApi; import org.signal.core.util.concurrent.DeadlockDetector; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.contentproviders.BlobProvider; +import org.signal.donations.permits.DonationPermitsRepository; import org.signal.libsignal.net.Network; import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.zkgroup.GenericServerPublicParams; import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerPublicParams; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; import org.signal.network.api.ArchiveApi; @@ -44,9 +46,12 @@ import org.signal.network.api.SvrBApi; import org.signal.network.api.UsernameApi; import org.signal.network.rest.SignalRestClient; import org.signal.network.service.MessageService; +import org.signal.video.exo.ExoPlayerPool; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.TypingStatusSender; +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits; +import org.thoughtcrime.securesms.components.settings.app.subscription.permits.NetworkDonationPermitIssuer; import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; import org.thoughtcrime.securesms.crypto.storage.SignalBaseIdentityKeyStore; @@ -108,9 +113,8 @@ import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.PreKeyBatcher; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; -import org.signal.video.exo.ExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; +import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; import org.whispersystems.signalservice.api.SignalServiceAccountDataStore; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -505,7 +509,16 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { @Override public @NonNull DonationsService provideDonationsService(@NonNull DonationsApi donationsApi) { - return new DonationsService(donationsApi); + return new DonationsService(donationsApi, DonationPermits.INSTANCE); + } + + @Override + public @NonNull DonationPermitsRepository provideDonationPermitsRepository(@NonNull byte[] zkGroupServerPublicParams) { + try { + return new DonationPermitsRepository(NetworkDonationPermitIssuer.INSTANCE, new ServerPublicParams(zkGroupServerPublicParams)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 0edccc4d6e..8120e3d3a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -463,6 +463,10 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) clearLocalCredentials() } + if ((previous && !registered) || isAciChanged) { + AppDependencies.donationPermitsRepository.clearPermits() + } + if (registered && (!previous || isAciChanged)) { registeredAtTimestamp = System.currentTimeMillis() } else if (!registered) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt index 980fb32695..be6d6daaac 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt @@ -62,6 +62,7 @@ class RecurringInAppPaymentRepositoryTest { every { StorageSyncHelper.scheduleSyncForDataChange() } returns Unit every { AppDependencies.donationsService.putSubscription(any()) } returns ServiceResponse.forResult(EmptyResponse.INSTANCE, 200, "") + every { AppDependencies.donationsService.createSubscriber(any()) } returns ServiceResponse.forResult(EmptyResponse.INSTANCE, 200, "") every { AppDependencies.donationsService.updateSubscriptionLevel(any(), any(), any(), any(), any()) } returns ServiceResponse.forResult(EmptyResponse.INSTANCE, 200, "") } @@ -96,6 +97,8 @@ class RecurringInAppPaymentRepositoryTest { val newSubscriber = ref.get() assertThat(newSubscriber).isNotEqualTo(initialSubscriber) + verify { AppDependencies.donationsService.createSubscriber(any()) } + verify(inverse = true) { AppDependencies.donationsService.putSubscription(any()) } } @Test @@ -111,6 +114,8 @@ class RecurringInAppPaymentRepositoryTest { val newSubscriber = ref.get() assertThat(newSubscriber).isNotEqualTo(initialSubscriber) + verify { AppDependencies.donationsService.createSubscriber(any()) } + verify(inverse = true) { AppDependencies.donationsService.putSubscription(any()) } } @Test diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermitsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermitsTest.kt new file mode 100644 index 0000000000..ec02a25fbc --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/DonationPermitsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.permits + +import android.app.Application +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.Base64 +import org.signal.donations.permits.DonationPermitError +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.zkgroup.ServerSecretParams +import org.signal.libsignal.zkgroup.donation.DonationPermit +import org.signal.libsignal.zkgroup.donation.DonationPermitDerivedKeyPair +import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext +import org.signal.libsignal.zkgroup.donation.DonationPermitResponse +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class DonationPermitsTest { + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + @Before + fun setUp() { + mockkObject(SignalStore) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `the spent permit is base64 encoded`() { + val permit = mintPermit() + every { AppDependencies.donationPermitsRepository.spendOrAcquirePermit(any()) } returns permit.right() + + assertThat(DonationPermits.getDonationPermit()).isEqualTo(RequestResult.Success(Base64.encodeWithPadding(permit.serialize()))) + } + + @Test + fun `when a permit cannot be obtained then a non-success result is returned`() { + every { AppDependencies.donationPermitsRepository.spendOrAcquirePermit(any()) } returns DonationPermitError.IssuerUnavailable(statusCode = 429).left() + + assertThat(DonationPermits.getDonationPermit()).isInstanceOf(RequestResult.NonSuccess::class) + } + + private fun mintPermit(): DonationPermit { + val secret = ServerSecretParams.generate() + val now = Instant.ofEpochSecond(1_700_000_000) + val context = DonationPermitRequestContext.forCount(1) + val keyPair = DonationPermitDerivedKeyPair.forExpiration(DonationPermitResponse.defaultExpiration(now), secret) + val response = context.request().issue(keyPair) + return context.receive(response, secret.publicParams, now).single() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuerTest.kt new file mode 100644 index 0000000000..0c6e0f0667 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/permits/NetworkDonationPermitIssuerTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.permits + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import io.mockk.every +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.donations.permits.DonationPermitError +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RestStatusCodeError +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class NetworkDonationPermitIssuerTest { + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + @Test + fun `a successful response yields the serialized permit response bytes`() { + val responseBytes = byteArrayOf(4, 5, 6) + every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.Success(responseBytes) + + val result = NetworkDonationPermitIssuer.issue(byteArrayOf(1)) + + assertThat(result.getOrNull()?.toList()).isEqualTo(responseBytes.toList()) + } + + @Test + fun `a status-code error yields an IssuerUnavailable error`() { + every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(429, emptyMap(), null)) + + val result = NetworkDonationPermitIssuer.issue(byteArrayOf(1)) + + assertThat(result.leftOrNull()).isNotNull().isInstanceOf(DonationPermitError.IssuerUnavailable::class) + } + + @Test + fun `a network error yields an IssuerUnavailable error`() { + every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.RetryableNetworkError(IOException("boom")) + + val result = NetworkDonationPermitIssuer.issue(byteArrayOf(1)) + + assertThat(result.leftOrNull()).isNotNull().isInstanceOf(DonationPermitError.IssuerUnavailable::class) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 8f68d1a2f0..4e8f6f5d8b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -5,6 +5,7 @@ import io.mockk.mockk import org.signal.core.util.billing.BillingApi import org.signal.core.util.concurrent.DeadlockDetector import org.signal.core.util.contentproviders.BlobProvider +import org.signal.donations.permits.DonationPermitsRepository import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations @@ -218,6 +219,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } + override fun provideDonationPermitsRepository(zkGroupServerPublicParams: ByteArray): DonationPermitsRepository { + return mockk(relaxed = true) + } + override fun provideProfileService( profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, diff --git a/lib/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt b/core/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt similarity index 100% rename from lib/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt rename to core/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt diff --git a/lib/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt b/core/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt similarity index 100% rename from lib/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt rename to core/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt diff --git a/lib/donations/build.gradle.kts b/lib/donations/build.gradle.kts index 9aac031f9a..f04723f9bc 100644 --- a/lib/donations/build.gradle.kts +++ b/lib/donations/build.gradle.kts @@ -23,13 +23,17 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) + api(libs.arrow.core) + implementation(libs.kotlin.reflect) implementation(libs.jackson.module.kotlin) implementation(libs.jackson.core) + implementation(libs.libsignal.android) testImplementation(testLibs.robolectric.robolectric) { exclude(group = "com.google.protobuf", module = "protobuf-java") } + testImplementation(testFixtures(project(":lib:libsignal-service"))) api(libs.google.play.services.wallet) api(libs.square.okhttp3) diff --git a/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitIssuer.kt b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitIssuer.kt new file mode 100644 index 0000000000..229328aeb6 --- /dev/null +++ b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitIssuer.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import androidx.annotation.WorkerThread +import arrow.core.Either + +/** + * The network seam for obtaining permits from the issuing server, keeping `:lib:donations` free of the + * websocket/network stack. + */ +interface DonationPermitIssuer { + + /** + * Submits the serialized, blinded [requestBytes] to the issuing server, returning either the serialized + * `DonationPermitResponse` bytes or the reason the request could not be fulfilled. + */ + @WorkerThread + fun issue(requestBytes: ByteArray): Either +} + +/** The reasons a donation-permit acquisition can fail, surfaced via [Either] rather than thrown. */ +sealed interface DonationPermitError { + + /** The issuing server could not fulfil the request (non-success status, network error, or transport failure). */ + data class IssuerUnavailable(val statusCode: Int? = null, val cause: Throwable? = null) : DonationPermitError + + /** The issuer's response failed zkgroup verification against the pinned public params. */ + data object VerificationFailed : DonationPermitError + + /** The issuer's response could not be decoded. */ + data object MalformedResponse : DonationPermitError +} diff --git a/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitStore.kt b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitStore.kt new file mode 100644 index 0000000000..dd8962fe72 --- /dev/null +++ b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitStore.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import org.signal.libsignal.zkgroup.donation.DonationPermit +import java.time.Instant + +/** + * The single, process-wide store of unspent permits, backed by an in-memory FIFO queue. Permits are + * short-lived bearer secrets and are never written to disk. + */ +object DonationPermitStore { + + private val permits = ArrayDeque() + + /** Bumped on every [clear] so an in-flight acquisition can detect an intervening clear via [generation]. */ + private var generation = 0 + + /** The current store generation; pass back to [addAllIfGeneration]. */ + @Synchronized + fun generation(): Int = generation + + /** Adds freshly received permits to the store. */ + @Synchronized + fun addAll(permits: List) { + this.permits.addAll(permits) + } + + /** Adds [permits] only if the generation still matches [expectedGeneration]; returns true if stored. */ + @Synchronized + fun addAllIfGeneration(expectedGeneration: Int, permits: List): Boolean { + if (generation != expectedGeneration) { + return false + } + this.permits.addAll(permits) + return true + } + + /** Removes and returns the oldest unexpired permit, or null if none remain. Expired permits are dropped. */ + @Synchronized + fun take(now: Instant = Instant.now()): DonationPermit? { + while (permits.isNotEmpty()) { + val permit = permits.removeFirst() + if (now.isBefore(permit.expiration)) { + return permit + } + } + return null + } + + /** The number of unexpired permits currently held. */ + @Synchronized + fun size(now: Instant = Instant.now()): Int = permits.count { now.isBefore(it.expiration) } + + /** Drops all held permits and bumps [generation]. Permits are account-linked secrets; call on logout/deregister. */ + @Synchronized + fun clear() { + generation++ + permits.clear() + } +} diff --git a/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitsRepository.kt b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitsRepository.kt new file mode 100644 index 0000000000..f290d857f9 --- /dev/null +++ b/lib/donations/src/main/java/org/signal/donations/permits/DonationPermitsRepository.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import androidx.annotation.WorkerThread +import arrow.core.Either +import arrow.core.raise.either +import org.signal.core.util.logging.Log +import org.signal.libsignal.zkgroup.InvalidInputException +import org.signal.libsignal.zkgroup.ServerPublicParams +import org.signal.libsignal.zkgroup.VerificationFailedException +import org.signal.libsignal.zkgroup.donation.DonationPermit +import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext +import org.signal.libsignal.zkgroup.donation.DonationPermitResponse +import java.time.Instant + +/** + * Orchestrates the client side of the donation-permit flow: build a blinded request, exchange it with the + * issuing server, unblind the response into permits, store them, and hand them out one at a time for redemption. + * + * @param serverPublicParams the audited, app-pinned root public key the issuer's response is verified against. + */ +class DonationPermitsRepository( + private val issuer: DonationPermitIssuer, + private val serverPublicParams: ServerPublicParams +) { + + private val store = DonationPermitStore + + companion object { + private val TAG = Log.tag(DonationPermitsRepository::class.java) + + /** Default batch size: spend one and keep spares so a retry need not return to the issuer. */ + const val DEFAULT_PERMIT_BATCH = 3 + } + + /** Requests a fresh batch of [count] permits and stores them, or returns the reason it could not. */ + @WorkerThread + internal fun acquirePermits(count: Int = DEFAULT_PERMIT_BATCH, now: Instant = Instant.now()): Either = either { + require(count > 0) { "count must be greater than zero" } + + val generation = store.generation() + + val context = DonationPermitRequestContext.forCount(count) + val responseBytes = issuer.issue(context.request().serialize()).bind() + val permits = try { + context.receive(DonationPermitResponse(responseBytes), serverPublicParams, now) + } catch (e: VerificationFailedException) { + raise(DonationPermitError.VerificationFailed) + } catch (e: InvalidInputException) { + raise(DonationPermitError.MalformedResponse) + } + + if (store.addAllIfGeneration(generation, permits)) { + Log.i(TAG, "Acquired ${permits.size} donation permit(s).", null, true) + } else { + Log.w(TAG, "Permits were cleared while acquiring; discarded ${permits.size}.") + } + } + + @WorkerThread + internal fun spendPermit(now: Instant = Instant.now()): DonationPermit? { + val permit = store.take(now) + + if (permit != null) { + Log.i(TAG, "Spent a permit. ${store.size(now)} remaining.", null, true) + } else { + Log.i(TAG, "No permits.", null, true) + } + + return permit + } + + /** + * Retrieves a permit to present at a donation endpoint: spends a stored permit, acquiring a fresh batch first + * if none remain. Returns the reason it could not be obtained (issuer, verification, decode) so the caller can + * surface the failure rather than silently proceeding without a required permit. + */ + @WorkerThread + fun spendOrAcquirePermit(now: Instant = Instant.now()): Either = either { + val existing = spendPermit(now) + if (existing != null) { + existing + } else { + acquirePermits(now = now).bind() + spendPermit(now) ?: raise(DonationPermitError.MalformedResponse) + } + } + + /** The number of unexpired permits currently available to spend. */ + internal fun availablePermitCount(now: Instant = Instant.now()): Int = store.size(now) + + /** + * Drops all held permits. Permits are account-linked bearer secrets, so this must be called on + * logout/deregistration. A clear that races an in-flight acquire is handled by [DonationPermitStore]'s + * generation check, so neither path holds a lock across the network call. + */ + fun clearPermits() = store.clear() +} diff --git a/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitStoreTest.kt b/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitStoreTest.kt new file mode 100644 index 0000000000..ef18d0a9d1 --- /dev/null +++ b/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitStoreTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.time.temporal.ChronoUnit + +class DonationPermitStoreTest { + + private val now = Instant.ofEpochSecond(1_700_000_000) + private val store = DonationPermitStore + private val server = TestDonationPermitServer() + + @Before + fun setUp() { + store.clear() + } + + @After + fun tearDown() { + store.clear() + } + + @Test + fun `take returns permits in FIFO order then null`() { + val permits = server.mint(3, now) + store.addAll(permits) + + assertArrayEquals(permits[0].serialize(), store.take(now)!!.serialize()) + assertArrayEquals(permits[1].serialize(), store.take(now)!!.serialize()) + assertArrayEquals(permits[2].serialize(), store.take(now)!!.serialize()) + assertNull(store.take(now)) + } + + @Test + fun `take drops expired permits and returns the next valid one`() { + val early = server.mint(1, now) + val later = server.mint(1, now.plus(7, ChronoUnit.DAYS)) + store.addAll(early + later) + + val afterEarlyExpiry = early.single().expiration.plusSeconds(1) + assertArrayEquals(later.single().serialize(), store.take(afterEarlyExpiry)!!.serialize()) + assertNull(store.take(afterEarlyExpiry)) + } + + @Test + fun `size counts only unexpired permits`() { + val permits = server.mint(2, now) + store.addAll(permits) + + assertEquals(2, store.size(now)) + assertEquals(0, store.size(permits.first().expiration)) + } + + @Test + fun `clear removes all permits`() { + store.addAll(server.mint(2, now)) + + store.clear() + + assertEquals(0, store.size(now)) + assertNull(store.take(now)) + } + + @Test + fun `addAllIfGeneration stores permits when the generation is unchanged`() { + val generation = store.generation() + + assertTrue(store.addAllIfGeneration(generation, server.mint(2, now))) + assertEquals(2, store.size(now)) + } + + @Test + fun `addAllIfGeneration discards permits when a clear changed the generation`() { + val generation = store.generation() + store.clear() + + assertFalse(store.addAllIfGeneration(generation, server.mint(2, now))) + assertEquals(0, store.size(now)) + } + + @Test + fun `clear advances the generation`() { + val generation = store.generation() + + store.clear() + + assertFalse(generation == store.generation()) + } + + @Test + fun `permit is expired at its expiration instant`() { + val permit = server.mint(1, now).single() + store.addAll(listOf(permit)) + + assertNull(store.take(permit.expiration)) + } +} diff --git a/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitsRepositoryTest.kt b/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitsRepositoryTest.kt new file mode 100644 index 0000000000..d09257cec5 --- /dev/null +++ b/lib/donations/src/test/java/org/signal/donations/permits/DonationPermitsRepositoryTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class DonationPermitsRepositoryTest { + + private val now = Instant.ofEpochSecond(1_700_000_000) + private val server = TestDonationPermitServer() + private val issuer = TestDonationPermitIssuer(server, now) + private val repository = DonationPermitsRepository(issuer, server.publicParams) + + @Before + fun setUp() { + DonationPermitStore.clear() + } + + @After + fun tearDown() { + DonationPermitStore.clear() + } + + @Test + fun `acquirePermits stores the requested number of permits`() { + repository.acquirePermits(3, now) + + assertEquals(3, repository.availablePermitCount(now)) + assertEquals(1, issuer.issueCount) + } + + @Test + fun `acquirePermits with non-positive count throws and does not contact the issuer`() { + assertThrows(IllegalArgumentException::class.java) { repository.acquirePermits(0, now) } + assertEquals(0, issuer.issueCount) + } + + @Test + fun `spendOrAcquirePermit returns an error when the issuer fails`() { + issuer.error = DonationPermitError.IssuerUnavailable(statusCode = 429) + + assertTrue(repository.spendOrAcquirePermit(now).isLeft()) + assertEquals(0, repository.availablePermitCount(now)) + } + + @Test + fun `spendOrAcquirePermit returns an error when verification fails`() { + val pinnedToDifferentServer = DonationPermitsRepository(issuer, TestDonationPermitServer().publicParams) + + assertTrue(pinnedToDifferentServer.spendOrAcquirePermit(now).isLeft()) + assertEquals(0, repository.availablePermitCount(now)) + } + + @Test + fun `spendOrAcquirePermit returns an error when the response is malformed`() { + issuer.returnMalformed = true + + assertTrue(repository.spendOrAcquirePermit(now).isLeft()) + assertEquals(0, repository.availablePermitCount(now)) + } + + @Test + fun `spendPermit returns distinct permits then null`() { + repository.acquirePermits(2, now) + + val first = repository.spendPermit(now) + assertNotNull(first) + assertEquals(1, repository.availablePermitCount(now)) + + val second = repository.spendPermit(now) + assertNotNull(second) + assertEquals(0, repository.availablePermitCount(now)) + + assertFalse(first!!.serialize().contentEquals(second!!.serialize())) + assertNull(repository.spendPermit(now)) + } + + @Test + fun `spendPermit skips expired permits`() { + repository.acquirePermits(2, now) + + val afterExpiry = now.plus(30, ChronoUnit.DAYS) + assertNull(repository.spendPermit(afterExpiry)) + assertEquals(0, repository.availablePermitCount(afterExpiry)) + } + + @Test + fun `spendOrAcquirePermit returns a stored permit without contacting the issuer`() { + repository.acquirePermits(2, now) + assertEquals(1, issuer.issueCount) + + assertNotNull(repository.spendOrAcquirePermit(now).getOrNull()) + assertEquals(1, issuer.issueCount) + assertEquals(1, repository.availablePermitCount(now)) + } + + @Test + fun `spendOrAcquirePermit acquires a batch when none are stored`() { + assertNotNull(repository.spendOrAcquirePermit(now).getOrNull()) + assertEquals(1, issuer.issueCount) + assertEquals(DonationPermitsRepository.DEFAULT_PERMIT_BATCH - 1, repository.availablePermitCount(now)) + } + + @Test + fun `clearPermits is not blocked while an acquire network call is in flight`() { + val cleared = CountDownLatch(1) + issuer.onIssue = { + // Simulate logout landing on another thread while the issuer request is in flight. + thread { + repository.clearPermits() + cleared.countDown() + } + // If a lock were held across the network call, this clear would block until issue() returns. + assertTrue("clearPermits() blocked during an in-flight acquire", cleared.await(5, TimeUnit.SECONDS)) + } + + val result = repository.spendOrAcquirePermit(now) + + assertTrue(result.isLeft()) + assertEquals(0, repository.availablePermitCount(now)) + } + + @Test + fun `clearPermits drops all held permits`() { + repository.acquirePermits(3, now) + assertEquals(3, repository.availablePermitCount(now)) + + repository.clearPermits() + + assertEquals(0, repository.availablePermitCount(now)) + } + + @Test + fun `default batch size is used when count omitted`() { + repository.acquirePermits(now = now) + + assertEquals(DonationPermitsRepository.DEFAULT_PERMIT_BATCH, repository.availablePermitCount(now)) + } +} diff --git a/lib/donations/src/test/java/org/signal/donations/permits/TestDonationPermitServer.kt b/lib/donations/src/test/java/org/signal/donations/permits/TestDonationPermitServer.kt new file mode 100644 index 0000000000..28d5954986 --- /dev/null +++ b/lib/donations/src/test/java/org/signal/donations/permits/TestDonationPermitServer.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations.permits + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import org.signal.libsignal.zkgroup.ServerSecretParams +import org.signal.libsignal.zkgroup.donation.DonationPermit +import org.signal.libsignal.zkgroup.donation.DonationPermitDerivedKeyPair +import org.signal.libsignal.zkgroup.donation.DonationPermitRequest +import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext +import org.signal.libsignal.zkgroup.donation.DonationPermitResponse +import java.time.Instant + +/** + * Runs the real zkgroup issuance flow so tests exercise genuine [DonationPermit]s rather than fabricated bytes. + */ +class TestDonationPermitServer(private val secret: ServerSecretParams = ServerSecretParams.generate()) { + + val publicParams = secret.publicParams + + /** Mints [count] permits dated to [now]'s default expiration via the full client + server round-trip. */ + fun mint(count: Int, now: Instant): List { + val context = DonationPermitRequestContext.forCount(count) + val responseBytes = issue(context.request().serialize(), now) + return context.receive(DonationPermitResponse(responseBytes), publicParams, now) + } + + /** Server-side issuance: blindly signs [requestBytes] for [now]'s default expiration. */ + fun issue(requestBytes: ByteArray, now: Instant): ByteArray { + val expiration = DonationPermitResponse.defaultExpiration(now) + val keyPair = DonationPermitDerivedKeyPair.forExpiration(expiration, secret) + return DonationPermitRequest(requestBytes).issue(keyPair).serialize() + } +} + +/** + * A [DonationPermitIssuer] that issues against a [TestDonationPermitServer], with hooks to simulate failures. + */ +class TestDonationPermitIssuer( + private val server: TestDonationPermitServer, + private val now: Instant, + var error: DonationPermitError? = null, + var returnMalformed: Boolean = false, + /** Invoked while a request is "in flight", to simulate work (e.g. a clear) racing the network call. */ + var onIssue: () -> Unit = {} +) : DonationPermitIssuer { + + var issueCount: Int = 0 + private set + + override fun issue(requestBytes: ByteArray): Either { + issueCount++ + onIssue() + error?.let { return it.left() } + return (if (returnMalformed) byteArrayOf(1, 2, 3) else server.issue(requestBytes, now)).right() + } +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt new file mode 100644 index 0000000000..806bac27bd --- /dev/null +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api + +import org.signal.core.util.concurrent.safeBlockingGet +import org.signal.libsignal.net.RequestResult +import org.signal.network.exceptions.PushNetworkException +import org.signal.network.rest.RestStatusCodeError +import org.signal.network.util.JsonUtil +import org.signal.network.websocket.WebSocketRequestMessage +import org.signal.network.websocket.WebsocketResponse +import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import org.whispersystems.signalservice.internal.websocket.WebSocketConnection +import java.io.IOException +import java.util.concurrent.TimeoutException +import kotlin.reflect.KClass +import kotlin.reflect.cast +import kotlin.time.Duration + +/** + * A convenience method to convert a websocket request into a [RequestResult], mirroring + * [NetworkResult.fromWebSocketRequest] for callers that want the libsignal [RequestResult] surface while still + * sending over the [SignalWebSocket]. Non-2xx responses become [RequestResult.NonSuccess] carrying a + * [RestStatusCodeError]; transport failures become [RequestResult.RetryableNetworkError]. + */ +fun SignalWebSocket.fromWebSocketRequest( + request: WebSocketRequestMessage, + clazz: KClass, + timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT +): RequestResult { + return try { + val result: Result> = request(request, timeout) + .map { response: WebsocketResponse -> Result.success(response.toRequestResult(clazz)) } + .onErrorReturn { Result.failure(it) } + .safeBlockingGet() + + result.getOrThrow() + } catch (e: IOException) { + RequestResult.RetryableNetworkError(e) + } catch (e: TimeoutException) { + RequestResult.RetryableNetworkError(PushNetworkException(e)) + } catch (e: InterruptedException) { + RequestResult.RetryableNetworkError(PushNetworkException(e)) + } catch (e: Throwable) { + RequestResult.ApplicationError(e) + } +} + +private fun WebsocketResponse.toRequestResult(clazz: KClass): RequestResult { + return if (status < 200 || status > 299) { + RequestResult.NonSuccess(RestStatusCodeError(status, headers, body?.toByteArray())) + } else { + val value: T = when (clazz) { + Unit::class -> clazz.cast(Unit) + String::class -> clazz.cast(body) + else -> JsonUtil.fromJson(body, clazz.java) + } + RequestResult.Success(value) + } +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationPermitProvider.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationPermitProvider.kt new file mode 100644 index 0000000000..143d7a5159 --- /dev/null +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationPermitProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.donations + +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RestStatusCodeError + +/** + * Supplies the required `Donation-Permit` header value for the donation endpoints that need it, injected into + * [org.whispersystems.signalservice.api.services.DonationsService]. + */ +fun interface DonationPermitProvider { + /** + * The base64-encoded single-use donation permit for the `Donation-Permit` header, or a non-success + * [RequestResult] describing why one could not be obtained (e.g. a network error vs. another failure). May + * perform network I/O, so call off the main thread. + */ + fun getDonationPermit(): RequestResult +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationsApi.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationsApi.kt index 3df5c29941..1a96a2e3fa 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationsApi.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/donations/DonationsApi.kt @@ -5,13 +5,17 @@ package org.whispersystems.signalservice.api.donations +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty import org.signal.core.util.Base64 import org.signal.core.util.urlEncode +import org.signal.libsignal.net.RequestResult import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse import org.signal.network.NetworkResult import org.signal.network.exceptions.MalformedResponseException +import org.signal.network.rest.RestStatusCodeError import org.signal.network.websocket.WebSocketRequestMessage import org.signal.network.websocket.delete import org.signal.network.websocket.get @@ -75,7 +79,8 @@ class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSo } /** - * Creates a subscriber record on the signal server. + * Refreshes an existing subscriber's access time (keep-alive). Does not attach a donation permit; use + * [createSubscriber] when creating a new subscriber. * * PUT /v1/subscription/[subscriberId] * - 200: Success @@ -86,6 +91,18 @@ class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSo return NetworkResult.fromWebSocketRequest(unauthWebSocket, request) } + /** + * Creates a subscriber record on the signal server, attaching a required single-use donation permit. + * + * PUT /v1/subscription/[subscriberId] + * - 200: Success + * - 403, 404: Invalid or malformed [subscriberId] + */ + fun createSubscriber(subscriberId: SubscriberId, donationPermit: String): NetworkResult { + val request = WebSocketRequestMessage.put("/v1/subscription/${subscriberId.serialize()}", body = "", headers = donationPermitHeaders(donationPermit)) + return NetworkResult.fromWebSocketRequest(unauthWebSocket, request) + } + /** * DELETE /v1/subscription/[subscriberId] * - 204: Success @@ -114,9 +131,9 @@ class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSo * @param amount Price, in the minimum currency unit (e.g. cents or yen) * @param currencyCode The currency code for the amount */ - fun createStripeOneTimePaymentIntent(currencyCode: String, paymentMethod: String, amount: Long, level: Long): NetworkResult { + fun createStripeOneTimePaymentIntent(currencyCode: String, paymentMethod: String, amount: Long, level: Long, donationPermit: String): NetworkResult { val body = StripeOneTimePaymentIntentPayload(amount, currencyCode, level, paymentMethod) - val request = WebSocketRequestMessage.post("/v1/subscription/boost/create", body) + val request = WebSocketRequestMessage.post("/v1/subscription/boost/create", body, donationPermitHeaders(donationPermit)) return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, StripeClientSecret::class) } @@ -126,8 +143,8 @@ class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSo * * @param type One of CARD or SEPA_DEBIT */ - fun createStripeSubscriptionPaymentMethod(subscriberId: SubscriberId, type: String): NetworkResult { - val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/create_payment_method?type=${type.urlEncode()}", "") + fun createStripeSubscriptionPaymentMethod(subscriberId: SubscriberId, type: String, donationPermit: String): NetworkResult { + val request = WebSocketRequestMessage.post("/v1/subscription/${subscriberId.serialize()}/create_payment_method?type=${type.urlEncode()}", "", donationPermitHeaders(donationPermit)) return NetworkResult.fromWebSocketRequest(unauthWebSocket, request, StripeClientSecret::class) } @@ -319,7 +336,39 @@ class DonationsApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSo return NetworkResult.fromWebSocketRequest(authWebSocket, request) } + /** + * Exchanges a blinded donation-permit request for the issuing server's response, returning the serialized + * libsignal `DonationPermitResponse` bytes. + * + * POST /v1/donation/permit + * - 200: Success + * - 429: Rate limited by the per-account request frequency limit. + */ + fun createDonationPermits(permitRequest: ByteArray): RequestResult { + val request = WebSocketRequestMessage.post("/v1/donation/permit", DonationPermitRequestBody(permitRequest)) + return authWebSocket.fromWebSocketRequest(request, DonationPermitResponseJson::class) + .map { it.permitResponse } + } + private fun Locale.toAcceptLanguageHeaders(): Map { return mapOf("Accept-Language" to "${this.language}${if (this.country.isNotEmpty()) "-${this.country}" else ""}") } + + /** The base64 donation permit as the `Donation-Permit` header. */ + private fun donationPermitHeaders(donationPermit: String): Map { + return mapOf("Donation-Permit" to donationPermit) + } +} + +/** Request body for `POST /v1/donation/permit`: the base64-encoded, serialized libsignal `DonationPermitRequest`. */ +private class DonationPermitRequestBody(permitRequest: ByteArray) { + @JsonProperty("permitRequest") + val permitRequest: String = Base64.encodeWithPadding(permitRequest) +} + +/** Response body for `POST /v1/donation/permit`: the base64-encoded, serialized libsignal `DonationPermitResponse`. */ +private class DonationPermitResponseJson @JsonCreator constructor( + @JsonProperty("permitResponse") encoded: String +) { + val permitResponse: ByteArray = Base64.decode(encoded) } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index d10676bb78..d7ee556ca0 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -5,7 +5,9 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; import org.signal.network.NetworkResult; +import org.signal.network.rest.RequestResultExtensionsKt; import org.whispersystems.signalservice.api.NetworkResultUtil; +import org.whispersystems.signalservice.api.donations.DonationPermitProvider; import org.whispersystems.signalservice.api.donations.DonationsApi; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; @@ -41,7 +43,8 @@ public class DonationsService { private static final String TAG = DonationsService.class.getSimpleName(); - private final DonationsApi donationsApi; + private final DonationsApi donationsApi; + private final DonationPermitProvider donationPermitProvider; private final AtomicReference> donationsConfigurationCache = new AtomicReference<>(null); private final AtomicReference> sepaBankMandateCache = new AtomicReference<>(null); @@ -58,8 +61,9 @@ public class DonationsService { } } - public DonationsService(@NonNull DonationsApi donationsApi) { - this.donationsApi = donationsApi; + public DonationsService(@NonNull DonationsApi donationsApi, @NonNull DonationPermitProvider donationPermitProvider) { + this.donationsApi = donationsApi; + this.donationPermitProvider = donationPermitProvider; } /** @@ -100,7 +104,11 @@ public class DonationsService { * @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway. */ public ServiceResponse createDonationIntentWithAmount(String amount, String currencyCode, long level, String paymentMethod) { - return wrapInServiceResponse(() -> new Pair<>(NetworkResultUtil.successOrThrow(donationsApi.createStripeOneTimePaymentIntent(currencyCode, paymentMethod, Long.parseLong(amount), level)), 200)); + return wrapInServiceResponse(() -> { + String donationPermit = RequestResultExtensionsKt.successOrThrow(donationPermitProvider.getDonationPermit()); + StripeClientSecret clientSecret = NetworkResultUtil.successOrThrow(donationsApi.createStripeOneTimePaymentIntent(currencyCode, paymentMethod, Long.parseLong(amount), level, donationPermit)); + return new Pair<>(clientSecret, 200); + }); } /** @@ -214,9 +222,7 @@ public class DonationsService { } /** - * Creates a subscriber record on the signal server and stripe. Can be called idempotently as-is. After receiving 200 from this endpoint, - * clients should save subscriberId locally and to storage service for the account. If you get a 403 from this endpoint and you did not - * use an account authenticated connection, then the subscriberId has been corrupted in some way. + * Refreshes an existing subscriber's access time (keep-alive). Does not attach a donation permit; use {@link #createSubscriber} for a new subscriber. *

* Clients MUST periodically hit this endpoint to update the access time on the subscription record. Recommend trying to call it approximately * every 3 days. Not accessing this endpoint for an extended period of time will result in the subscription being canceled. @@ -230,6 +236,21 @@ public class DonationsService { }); } + /** + * Creates a subscriber record on the signal server and stripe, attaching a single-use donation permit. Only use when the subscriber does not + * yet exist. After receiving 200 from this endpoint, clients should save subscriberId locally and to storage service for the account. If you + * get a 403 from this endpoint and you did not use an account authenticated connection, then the subscriberId has been corrupted in some way. + * + * @param subscriberId The subscriber ID for the newly-created subscriber + */ + public ServiceResponse createSubscriber(SubscriberId subscriberId) { + return wrapInServiceResponse(() -> { + String donationPermit = RequestResultExtensionsKt.successOrThrow(donationPermitProvider.getDonationPermit()); + NetworkResultUtil.successOrThrow(donationsApi.createSubscriber(subscriberId, donationPermit)); + return new Pair<>(EmptyResponse.INSTANCE, 200); + }); + } + /** * Cancels any current subscription at the end of the current subscription period. * @@ -263,7 +284,8 @@ public class DonationsService { */ public ServiceResponse createStripeSubscriptionPaymentMethod(SubscriberId subscriberId, String type) { return wrapInServiceResponse(() -> { - StripeClientSecret clientSecret = NetworkResultUtil.successOrThrow(donationsApi.createStripeSubscriptionPaymentMethod(subscriberId, type)); + String donationPermit = RequestResultExtensionsKt.successOrThrow(donationPermitProvider.getDonationPermit()); + StripeClientSecret clientSecret = NetworkResultUtil.successOrThrow(donationsApi.createStripeSubscriptionPaymentMethod(subscriberId, type, donationPermit)); return new Pair<>(clientSecret, 200); }); } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/donations/DonationsApiTest.kt b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/donations/DonationsApiTest.kt new file mode 100644 index 0000000000..14b4d81ecc --- /dev/null +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/donations/DonationsApiTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.donations + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isFalse +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.reactivex.rxjava3.core.Single +import org.junit.Test +import org.signal.network.websocket.WebSocketRequestMessage +import org.signal.network.websocket.WebsocketResponse +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import kotlin.time.Duration + +class DonationsApiTest { + + private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket = mockk() + private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket = mockk() + private val api = DonationsApi(authWebSocket, unauthWebSocket) + + @Test + fun `createSubscriber attaches the Donation-Permit header`() { + val request = captureRequest() + + api.createSubscriber(SubscriberId.generate(), "permit-abc") + + assertThat(request.captured.headers).contains("Donation-Permit:permit-abc") + } + + @Test + fun `putSubscription omits the Donation-Permit header`() { + val request = captureRequest() + + api.putSubscription(SubscriberId.generate()) + + assertThat(request.captured.headers.any { it.startsWith("Donation-Permit") }).isFalse() + } + + @Test + fun `createStripeSubscriptionPaymentMethod attaches the Donation-Permit header when a permit is provided`() { + val request = captureRequest() + + api.createStripeSubscriptionPaymentMethod(SubscriberId.generate(), "CARD", "permit-xyz") + + assertThat(request.captured.headers).contains("Donation-Permit:permit-xyz") + } + + @Test + fun `createStripeOneTimePaymentIntent attaches the Donation-Permit header when a permit is provided`() { + val request = captureRequest() + + api.createStripeOneTimePaymentIntent("USD", "CARD", 500, 1, "permit-123") + + assertThat(request.captured.headers).contains("Donation-Permit:permit-123") + } + + private fun captureRequest(): CapturingSlot { + val slot = slot() + every { unauthWebSocket.request(capture(slot), any()) } returns Single.just(WebsocketResponse(200, "{}", emptyMap(), false)) + return slot + } +} diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.kt b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.kt index e7baadd33d..745a3aae3b 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.kt +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.kt @@ -7,17 +7,25 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.signal.libsignal.net.RequestResult import org.signal.network.NetworkResult import org.signal.network.exceptions.NonSuccessfulResponseCodeException +import org.signal.network.rest.RestStatusCodeError +import org.whispersystems.signalservice.api.donations.DonationPermitProvider import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret import org.whispersystems.signalservice.api.subscriptions.SubscriberId class DonationsServiceTest { private val donationsApi: DonationsApi = mockk() - private val testSubject = DonationsService(donationsApi) + private val testSubject = DonationsService(donationsApi, DonationPermitProvider { RequestResult.Success("permit") }) private val activeSubscription = ActiveSubscription.EMPTY + private fun serviceWithPermit(permit: String) = DonationsService(donationsApi, DonationPermitProvider { RequestResult.Success(permit) }) + + private fun serviceWithPermitResult(result: RequestResult) = DonationsService(donationsApi, DonationPermitProvider { result }) + @Test fun givenASubscriberId_whenIGetASuccessfulResponse_thenItIsMappedWithTheCorrectStatusCodeAndNonEmptyObject() { // GIVEN @@ -47,4 +55,55 @@ class DonationsServiceTest { assertEquals(403, response.status) assertFalse(response.result.isPresent) } + + @Test + fun givenAPermitProvider_whenICreateDonationIntent_thenTheProvidersPermitIsForwarded() { + every { donationsApi.createStripeOneTimePaymentIntent(any(), any(), any(), any(), any()) } returns NetworkResult.Success(mockk()) + + serviceWithPermit("permit-x").createDonationIntentWithAmount("500", "USD", 1, "CARD") + + verify { donationsApi.createStripeOneTimePaymentIntent("USD", "CARD", 500, 1, "permit-x") } + } + + @Test + fun givenAPermitProvider_whenICreateStripeSubscriptionPaymentMethod_thenTheProvidersPermitIsForwarded() { + val subscriberId = SubscriberId.generate() + every { donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.Success(mockk()) + + serviceWithPermit("permit-x").createStripeSubscriptionPaymentMethod(subscriberId, "CARD") + + verify { donationsApi.createStripeSubscriptionPaymentMethod(subscriberId, "CARD", "permit-x") } + } + + @Test + fun givenAPermitProvider_whenICreateSubscriber_thenTheProvidersPermitIsForwarded() { + val subscriberId = SubscriberId.generate() + every { donationsApi.createSubscriber(any(), any()) } returns NetworkResult.Success(Unit) + + serviceWithPermit("permit-x").createSubscriber(subscriberId) + + verify { donationsApi.createSubscriber(subscriberId, "permit-x") } + } + + @Test + fun givenAFailingPermitProvider_whenICreateSubscriber_thenItFailsAndNoSubscriberIsCreated() { + val subscriberId = SubscriberId.generate() + every { donationsApi.createSubscriber(any(), any()) } returns NetworkResult.Success(Unit) + + val response = serviceWithPermitResult(RequestResult.NonSuccess(RestStatusCodeError(429, emptyMap(), null))).createSubscriber(subscriberId) + + assertEquals(429, response.status) + verify(exactly = 0) { donationsApi.createSubscriber(any(), any()) } + } + + @Test + fun whenIPutSubscription_thenNoPermitIsEverAttached() { + val subscriberId = SubscriberId.generate() + every { donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit) + + serviceWithPermit("permit-x").putSubscription(subscriberId) + + verify { donationsApi.putSubscription(subscriberId) } + verify(exactly = 0) { donationsApi.createSubscriber(any(), any()) } + } }