Add badge/backup entitlement RPC

This commit is contained in:
Ravi Khadiwala
2026-06-29 11:51:07 -05:00
committed by Jon Chambers
parent 9e3844ee5a
commit 6bb85d77a7
12 changed files with 169 additions and 66 deletions
@@ -1069,7 +1069,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDeliveryCertificate().embedSigner());
final List<ServerServiceDefinition> authenticatedServices = Stream.of(
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager, Clock.systemUTC()),
new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
@@ -7,6 +7,8 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
@@ -26,6 +28,8 @@ import org.signal.chat.account.DeleteUsernameLinkRequest;
import org.signal.chat.account.DeleteUsernameLinkResponse;
import org.signal.chat.account.GetAccountIdentityRequest;
import org.signal.chat.account.GetAccountIdentityResponse;
import org.signal.chat.account.GetEntitlementsRequest;
import org.signal.chat.account.GetEntitlementsResponse;
import org.signal.chat.account.ReserveUsernameHashRequest;
import org.signal.chat.account.ReserveUsernameHashResponse;
import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;
@@ -70,16 +74,19 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
private final RateLimiters rateLimiters;
private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final Clock clock;
public AccountsGrpcService(final AccountsManager accountsManager,
final RateLimiters rateLimiters,
final UsernameHashZkProofVerifier usernameHashZkProofVerifier,
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final Clock clock) {
this.accountsManager = accountsManager;
this.rateLimiters = rateLimiters;
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.clock = clock;
}
@Override
@@ -103,6 +110,32 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
.build();
}
@Override
public GetEntitlementsResponse getEntitlements(final GetEntitlementsRequest request) {
final Account account = getAuthenticatedAccount();
final GetEntitlementsResponse.Builder builder = GetEntitlementsResponse.newBuilder();
final Instant now = clock.instant();
final Account.BackupVoucher backupVoucher = account.getBackupVoucher();
if (backupVoucher != null && backupVoucher.expiration().isAfter(now)) {
builder.setBackup(GetEntitlementsResponse.BackupEntitlement.newBuilder()
.setExpirationEpochSeconds(backupVoucher.expiration().getEpochSecond())
.setLevel(backupVoucher.receiptLevel()));
}
builder.addAllBadges(account.getBadges().stream()
.filter(badge -> badge.expiration().isAfter(now))
.map(badge -> GetEntitlementsResponse.BadgeEntitlement.newBuilder()
.setBadgeId(badge.id())
.setExpirationEpochSeconds(badge.expiration().getEpochSecond())
.setVisible(badge.visible())
.build())
.toList());
return builder.build();
}
@Override
public DeleteAccountResponse deleteAccount(final DeleteAccountRequest request) {
accountsManager.delete(AuthenticationUtil.requireAuthenticatedPrimaryDevice().accountIdentifier(),
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import org.signal.chat.common.Badge;
import org.signal.chat.common.BadgeSvg;
public class BadgeGrpcHelper {
public static Badge toGrpcBadge(final org.whispersystems.textsecuregcm.entities.Badge badge) {
final Badge.Builder builder = Badge.newBuilder()
.setId(badge.getId())
.setCategory(badge.getCategory())
.setName(badge.getName())
.setDescription(badge.getDescription())
.addAllSprites6(badge.getSprites6())
.setSvg(badge.getSvg());
badge.getSvgs().stream()
.map(badgeSvg -> BadgeSvg.newBuilder()
.setDark(badgeSvg.getDark())
.setLight(badgeSvg.getLight())
.build())
.forEach(builder::addSvgs);
return builder.build();
}
}
@@ -13,8 +13,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.signal.chat.common.Badge;
import org.signal.chat.common.BadgeSvg;
import org.signal.chat.purchase.AmountList;
import org.signal.chat.purchase.BackupConfiguration;
import org.signal.chat.purchase.BackupLevelConfiguration;
@@ -158,22 +156,12 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
private static LevelConfiguration toProtoLevelConfiguration(
final org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration levelConfiguration) {
final org.whispersystems.textsecuregcm.entities.Badge badge = levelConfiguration.badge();
final Badge commonBadge = Badge.newBuilder()
.setId(badge.getId())
.setCategory(badge.getCategory())
.setName(badge.getName())
.setDescription(badge.getDescription())
.addAllSprites6(badge.getSprites6())
.setSvg(badge.getSvg())
.addAllSvgs(badge.getSvgs().stream()
.map(s -> BadgeSvg.newBuilder().setLight(s.getLight()).setDark(s.getDark()).build())
.toList())
.build();
final LevelConfiguration.Builder builder = LevelConfiguration.newBuilder().setBadge(commonBadge);
if (badge instanceof final PurchasableBadge purchasableBadge) {
final LevelConfiguration.Builder builder = LevelConfiguration.newBuilder();
if (levelConfiguration.badge() instanceof final PurchasableBadge purchasableBadge) {
builder.setBadgeDurationSeconds(purchasableBadge.getDuration().toSeconds());
}
return builder.build();
return builder
.setBadge(BadgeGrpcHelper.toGrpcBadge(levelConfiguration.badge()))
.build();
}
}
@@ -129,7 +129,6 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
return targetAccount.map(account ->
GetUnversionedProfileAnonymousResponse.newBuilder()
.setResult(ProfileGrpcHelper.buildUnversionedProfileResult(targetIdentifier,
null,
account,
profileBadgeConverter))
.build())
@@ -10,14 +10,11 @@ import com.google.protobuf.ByteString;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.signal.chat.common.Badge;
import org.signal.chat.common.BadgeSvg;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.profile.DataEtag;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResult;
@@ -33,6 +30,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
@@ -129,23 +127,6 @@ public class ProfileGrpcHelper {
return Optional.of(responseBuilder.build());
}
@VisibleForTesting
static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) {
final ArrayList<Badge> grpcBadges = new ArrayList<>();
for (final org.whispersystems.textsecuregcm.entities.Badge badge : badges) {
grpcBadges.add(Badge.newBuilder()
.setId(badge.getId())
.setCategory(badge.getCategory())
.setName(badge.getName())
.setDescription(badge.getDescription())
.addAllSprites6(badge.getSprites6())
.setSvg(badge.getSvg())
.addAllSvgs(buildBadgeSvgs(badge.getSvgs()))
.build());
}
return grpcBadges;
}
@VisibleForTesting
static List<org.signal.chat.common.DeviceCapability> buildAccountCapabilities(final Account account) {
return Arrays.stream(DeviceCapability.values())
@@ -155,20 +136,8 @@ public class ProfileGrpcHelper {
.toList();
}
private static List<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) {
ArrayList<BadgeSvg> grpcBadgeSvgs = new ArrayList<>();
for (final org.whispersystems.textsecuregcm.entities.BadgeSvg badgeSvg : badgeSvgs) {
grpcBadgeSvgs.add(BadgeSvg.newBuilder()
.setDark(badgeSvg.getDark())
.setLight(badgeSvg.getLight())
.build());
}
return grpcBadgeSvgs;
}
static GetUnversionedProfileResult buildUnversionedProfileResult(
final ServiceIdentifier targetIdentifier,
final UUID requesterUuid,
final Account targetAccount,
final ProfileBadgeConverter profileBadgeConverter) {
final GetUnversionedProfileResult.Builder resultBuilder = GetUnversionedProfileResult.newBuilder()
@@ -177,11 +146,13 @@ public class ProfileGrpcHelper {
switch (targetIdentifier.identityType()) {
case ACI -> {
resultBuilder.setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess())
.addAllBadges(
buildBadges(profileBadgeConverter.convert(RequestAttributesUtil.getAvailableAcceptedLocales(),
targetAccount.getBadges(),
ProfileHelper.isSelfProfileRequest(requesterUuid, targetIdentifier))));
final List<Locale> acceptedLocales = RequestAttributesUtil.getAvailableAcceptedLocales();
final List<Badge> badges =
profileBadgeConverter.convert(acceptedLocales, targetAccount.getBadges(), false);
resultBuilder
.setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess())
.addAllBadges(badges.stream().map(BadgeGrpcHelper::toGrpcBadge).toList());
targetAccount.getUnidentifiedAccessKey()
.map(UnidentifiedAccessChecksum::generateFor)
@@ -260,7 +260,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
return maybeAccount.map(account -> GetUnversionedProfileResponse.newBuilder()
.setResult(ProfileGrpcHelper.buildUnversionedProfileResult(targetIdentifier,
authenticatedDevice.accountIdentifier(),
account,
profileBadgeConverter))
.build()).orElseGet(() -> GetUnversionedProfileResponse.newBuilder()
@@ -14,6 +14,9 @@ service Accounts {
// Returns basic identifiers for the authenticated account.
rpc GetAccountIdentity(GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {}
// Returns entitlements for the authenticated account.
rpc GetEntitlements(GetEntitlementsRequest) returns (GetEntitlementsResponse) {}
// Deletes the authenticated account, purging all associated data in the
// process.
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse) {}
@@ -84,6 +87,35 @@ message GetAccountIdentityResponse {
common.AccountIdentifiers account_identifiers = 1;
}
message GetEntitlementsRequest {
}
message GetEntitlementsResponse {
message BadgeEntitlement {
// The id of the badge the account is entitled. Metadata to display for
// badges may be obtained by cross-referencing badge ids with
// ProductConfiguration.GetConfiguration.
string badge_id = 1;
// When the badge expires, in number of seconds since epoch
uint64 expiration_epoch_seconds = 2;
// Whether the badge is currently configured to be visible
bool visible = 3;
}
message BackupEntitlement {
// The backup level of the account
uint64 level = 1;
// When the backup entitlement expires, in number of seconds since epoch
uint64 expiration_epoch_seconds = 2;
}
// Active badges added via Donations.redeemReceipt
repeated BadgeEntitlement badges = 1;
// If present, the backup level set via Backups.redeemReceipt
BackupEntitlement backup = 2;
}
message DeleteAccountRequest {
}
@@ -292,13 +292,16 @@ message GetUnversionedProfileAnonymousRequest {
message GetUnversionedProfileResult {
// The identity key of the targeted account/identity type.
bytes identity_key = 1;
// A checksum of the unidentified access key for the targeted account.
// A checksum of the unidentified access key for the targeted account. Absent
// if the request was for a PNI.
bytes unidentified_access = 2;
// Whether the account has enabled sealed sender from anyone.
// Whether the account has enabled sealed sender from anyone. Always false
// if the request was for a PNI.
bool unrestricted_unidentified_access = 3;
// A list of capabilities enabled on the account.
repeated common.DeviceCapability capabilities = 4;
// A list of badges associated with the account.
// A list of badges associated with the account. Absent if the request was for
// a PNI.
repeated common.Badge badges = 5;
}
@@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -21,6 +22,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
@@ -46,6 +48,8 @@ import org.signal.chat.account.DeleteUsernameHashRequest;
import org.signal.chat.account.DeleteUsernameLinkRequest;
import org.signal.chat.account.GetAccountIdentityRequest;
import org.signal.chat.account.GetAccountIdentityResponse;
import org.signal.chat.account.GetEntitlementsRequest;
import org.signal.chat.account.GetEntitlementsResponse;
import org.signal.chat.account.ReserveUsernameHashRequest;
import org.signal.chat.account.ReserveUsernameHashResponse;
import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;
@@ -73,12 +77,14 @@ import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
@@ -97,6 +103,8 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
@Mock
private RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final TestClock testClock = TestClock.pinned(Instant.now());
@Override
protected AccountsGrpcService createServiceBeforeEachTest() {
AccountsHelper.setupMockUpdate(accountsManager);
@@ -111,7 +119,8 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
return new AccountsGrpcService(accountsManager,
rateLimiters,
usernameHashZkProofVerifier,
registrationRecoveryPasswordsManager);
registrationRecoveryPasswordsManager,
testClock);
}
@Test
@@ -765,4 +774,41 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
verify(accountsManager, never()).update(any(UUID.class), any());
verify(account, never()).setZkCredentialKey(any());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void getEntitlements(final boolean expired) {
final Account account = mock(Account.class);
final Instant expiration = expired
? testClock.instant().minus(Duration.ofDays(1))
: testClock.instant().plus(Duration.ofMillis(1));
final AccountBadge badge1 = new AccountBadge("badge1", expiration, true);
final AccountBadge badge2 = new AccountBadge("badge2", expiration, true);
when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(100, expiration));
when(account.getBadges()).thenReturn(List.of(badge1, badge2));
when(account.getUuid()).thenReturn(AUTHENTICATED_ACI);
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(account));
final GetEntitlementsResponse entitlements = authenticatedServiceStub()
.getEntitlements(GetEntitlementsRequest.newBuilder().build());
if (expired) {
assertThat(entitlements.getBadgesCount()).isEqualTo(0);
assertThat(entitlements.hasBackup()).isFalse();
} else {
assertThat(entitlements.getBadges(0)).isEqualTo(toBadgeEntitlement(badge1));
assertThat(entitlements.getBadges(1)).isEqualTo(toBadgeEntitlement(badge2));
assertThat(entitlements.getBackup().getLevel()).isEqualTo(100);
assertThat(entitlements.getBackup().getExpirationEpochSeconds()).isEqualTo(expiration.getEpochSecond());
}
}
private static GetEntitlementsResponse.BadgeEntitlement toBadgeEntitlement(AccountBadge badge) {
return GetEntitlementsResponse.BadgeEntitlement.newBuilder()
.setExpirationEpochSeconds(badge.expiration().getEpochSecond())
.setBadgeId(badge.id())
.setVisible(badge.visible())
.build();
}
}
@@ -219,7 +219,7 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
.setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
.setUnrestrictedUnidentifiedAccess(false)
.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET)
.addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.addAllBadges(badges.stream().map(BadgeGrpcHelper::toGrpcBadge).toList())
.build()
)
.build();
@@ -273,7 +273,7 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
.setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.setUnrestrictedUnidentifiedAccess(false)
.addAllCapabilities(ProfileGrpcHelper.buildAccountCapabilities(account))
.addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.addAllBadges(badges.stream().map(BadgeGrpcHelper::toGrpcBadge).toList())
.build();
verify(accountsManager).getByServiceIdentifier(serviceIdentifier);
@@ -655,7 +655,7 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
.setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
.setUnrestrictedUnidentifiedAccess(true)
.addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.addAllBadges(badges.stream().map(BadgeGrpcHelper::toGrpcBadge).toList())
.build();
final GetUnversionedProfileResult expectedResponse;