diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 02d1daf4c..efee5c7b9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -1069,7 +1069,7 @@ public class WhisperServerService extends Application 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), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java index afe0a8bb8..082ece6f3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java @@ -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(), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BadgeGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BadgeGrpcHelper.java new file mode 100644 index 000000000..17907c41e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BadgeGrpcHelper.java @@ -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(); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java index 8021dfa32..d3fab2e73 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java @@ -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(); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java index fc9c46dff..038942fbc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java @@ -129,7 +129,6 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof return targetAccount.map(account -> GetUnversionedProfileAnonymousResponse.newBuilder() .setResult(ProfileGrpcHelper.buildUnversionedProfileResult(targetIdentifier, - null, account, profileBadgeConverter)) .build()) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java index 90f6d8a7e..b77981f2f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -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 buildBadges(final List badges) { - final ArrayList 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 buildAccountCapabilities(final Account account) { return Arrays.stream(DeviceCapability.values()) @@ -155,20 +136,8 @@ public class ProfileGrpcHelper { .toList(); } - private static List buildBadgeSvgs(final List badgeSvgs) { - ArrayList 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 acceptedLocales = RequestAttributesUtil.getAvailableAcceptedLocales(); + final List badges = + profileBadgeConverter.convert(acceptedLocales, targetAccount.getBadges(), false); + + resultBuilder + .setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess()) + .addAllBadges(badges.stream().map(BadgeGrpcHelper::toGrpcBadge).toList()); targetAccount.getUnidentifiedAccessKey() .map(UnidentifiedAccessChecksum::generateFor) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java index b7c22c537..27a8f9fa0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java @@ -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() diff --git a/service/src/main/proto/org/signal/chat/account.proto b/service/src/main/proto/org/signal/chat/account.proto index cb357094c..c300b8597 100644 --- a/service/src/main/proto/org/signal/chat/account.proto +++ b/service/src/main/proto/org/signal/chat/account.proto @@ -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 { } diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index c39c0ad62..86655c066 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -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; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java index 1ae033e1d..957c755ee 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java @@ -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