diff --git a/integration-tests/src/main/java/org/signal/integration/TestUser.java b/integration-tests/src/main/java/org/signal/integration/TestUser.java index 6619637c6..9b1aef303 100644 --- a/integration-tests/src/main/java/org/signal/integration/TestUser.java +++ b/integration-tests/src/main/java/org/signal/integration/TestUser.java @@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import org.signal.libsignal.protocol.IdentityKey; @@ -127,7 +128,7 @@ public class TestUser { } public AccountAttributes accountAttributes() { - return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", true, new Device.DeviceCapabilities(false, false, false, false)) + return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", true, Set.of()) .withUnidentifiedAccessKey(unidentifiedAccessKey) .withRecoveryPassword(registrationPassword); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 648eb4b8e..10d149e8b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -60,6 +60,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; @@ -282,7 +283,7 @@ public class AccountController { auth.getAccount().getPhoneNumberIdentifier(), auth.getAccount().getUsernameHash().filter(h -> h.length > 0).orElse(null), auth.getAccount().getUsernameLinkHandle(), - auth.getAccount().isStorageSupported()); + auth.getAccount().hasCapability(DeviceCapability.STORAGE)); } @DELETE diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java index 34cd4d182..1288988a9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java @@ -54,6 +54,7 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.websocket.auth.Mutable; import org.whispersystems.websocket.auth.ReadOnly; @@ -151,7 +152,7 @@ public class AccountControllerV2 { updatedAccount.getPhoneNumberIdentifier(), updatedAccount.getUsernameHash().orElse(null), updatedAccount.getUsernameLinkHandle(), - updatedAccount.isStorageSupported()); + updatedAccount.hasCapability(DeviceCapability.STORAGE)); } catch (MismatchedDevicesException e) { throw new WebApplicationException(Response.status(409) .type(MediaType.APPLICATION_JSON_TYPE) @@ -210,7 +211,7 @@ public class AccountControllerV2 { updatedAccount.getPhoneNumberIdentifier(), updatedAccount.getUsernameHash().orElse(null), updatedAccount.getUsernameLinkHandle(), - updatedAccount.isStorageSupported()); + updatedAccount.hasCapability(DeviceCapability.STORAGE)); } catch (MismatchedDevicesException e) { throw new WebApplicationException(Response.status(409) .type(MediaType.APPLICATION_JSON_TYPE) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java index bec6a9505..60b6be92a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java @@ -4,6 +4,8 @@ */ package org.whispersystems.textsecuregcm.controllers; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.VisibleForTesting; import com.google.common.net.HttpHeaders; import io.dropwizard.auth.Auth; @@ -16,15 +18,19 @@ import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import javax.annotation.Nullable; import javax.validation.Valid; import javax.validation.constraints.Max; @@ -47,22 +53,21 @@ import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import io.swagger.v3.oas.annotations.tags.Tag; import org.glassfish.jersey.server.ContainerRequest; -import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices; +import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider; import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest; import org.whispersystems.textsecuregcm.entities.DeviceInfo; import org.whispersystems.textsecuregcm.entities.DeviceInfoList; -import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest; -import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse; import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest; +import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse; import org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator; import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; import org.whispersystems.textsecuregcm.entities.RemoteAttachment; +import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest; import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest; import org.whispersystems.textsecuregcm.identity.IdentityType; @@ -74,9 +79,10 @@ import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager; import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException; +import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter; import org.whispersystems.textsecuregcm.util.EnumMapUtil; import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.LinkDeviceToken; @@ -270,7 +276,7 @@ public class DeviceController { throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit); } - final DeviceCapabilities capabilities = accountAttributes.getCapabilities(); + final Set capabilities = accountAttributes.getCapabilities(); if (capabilities == null) { throw new WebApplicationException(Response.status(422, "Missing device capabilities").build()); @@ -405,7 +411,13 @@ public class DeviceController { @PUT @Produces(MediaType.APPLICATION_JSON) @Path("/capabilities") - public void setCapabilities(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid DeviceCapabilities capabilities) { + public void setCapabilities(@Mutable @Auth final AuthenticatedDevice auth, + + @NotNull + @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class) + @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class) + final Set capabilities) { + assert (auth.getAuthenticatedDevice() != null); final byte deviceId = auth.getAuthenticatedDevice().getId(); accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities)); @@ -433,11 +445,13 @@ public class DeviceController { setPublicKeyRequest.publicKey()); } - private static boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) { - boolean isDowngrade = false; - isDowngrade |= account.isDeleteSyncSupported() && !capabilities.deleteSync(); - isDowngrade |= account.isVersionedExpirationTimerSupported() && !capabilities.versionedExpirationTimer(); - return isDowngrade; + private static boolean isCapabilityDowngrade(final Account account, final Set capabilities) { + final Set requiredCapabilities = Arrays.stream(DeviceCapability.values()) + .filter(DeviceCapability::preventDowngrade) + .filter(account::hasCapability) + .collect(Collectors.toSet()); + + return !capabilities.containsAll(requiredCapabilities); } @PUT diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index fd6de0a34..eca7f6f55 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -73,7 +73,6 @@ import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; -import org.whispersystems.textsecuregcm.entities.UserCapabilities; import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; @@ -85,6 +84,7 @@ import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; @@ -431,7 +431,7 @@ public class ProfileController { return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI), account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null), account.isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(account), + getAccountCapabilities(account), profileBadgeConverter.convert( HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext), account.getBadges(), @@ -443,7 +443,7 @@ public class ProfileController { return new BaseProfileResponse(account.getIdentityKey(IdentityType.PNI), null, false, - UserCapabilities.createForAccount(account), + getAccountCapabilities(account), Collections.emptyList(), new PniServiceIdentifier(account.getPhoneNumberIdentifier())); } @@ -489,4 +489,9 @@ public class ProfileController { now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature); } + private static Map getAccountCapabilities(final Account account) { + return Arrays.stream(DeviceCapability.values()) + .filter(DeviceCapability::includeInProfile) + .collect(Collectors.toMap(Enum::name, account::hasCapability)); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java index 2bb4fa7f4..a048ffce4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java @@ -40,11 +40,11 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest; import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure; import org.whispersystems.textsecuregcm.entities.RegistrationRequest; -import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.Util; @@ -127,7 +127,7 @@ public class RegistrationController { REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays()); }); - if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(Account::isTransferSupported).orElse(false)) { + if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(account -> account.hasCapability(DeviceCapability.TRANSFER)).orElse(false)) { // If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user) // before we'll let them create a new account "from scratch" throw new WebApplicationException(Response.status(409, "device transfer available").build()); @@ -171,7 +171,7 @@ public class RegistrationController { account.getPhoneNumberIdentifier(), account.getUsernameHash().orElse(null), account.getUsernameLinkHandle(), - existingAccount.map(Account::isStorageSupported).orElse(false)); + existingAccount.map(a -> a.hasCapability(DeviceCapability.STORAGE)).orElse(false)); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java index 5ed0c95c9..6980b77d6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java @@ -11,12 +11,14 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.VisibleForTesting; import java.util.Optional; +import java.util.Set; import javax.annotation.Nullable; import javax.validation.constraints.AssertTrue; import javax.validation.constraints.Size; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; +import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter; import org.whispersystems.textsecuregcm.util.ExactlySize; public class AccountAttributes { @@ -47,7 +49,10 @@ public class AccountAttributes { private boolean unrestrictedUnidentifiedAccess; @JsonProperty - private DeviceCapabilities capabilities; + @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class) + @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class) + @Nullable + private Set capabilities; @JsonProperty private boolean discoverableByPhoneNumber = true; @@ -68,7 +73,7 @@ public class AccountAttributes { final byte[] name, final String registrationLock, final boolean discoverableByPhoneNumber, - final DeviceCapabilities capabilities) { + final Set capabilities) { this.fetchesMessages = fetchesMessages; this.registrationId = registrationId; this.phoneNumberIdentityRegistrationId = phoneNumberIdentifierRegistrationId; @@ -106,7 +111,8 @@ public class AccountAttributes { return unrestrictedUnidentifiedAccess; } - public DeviceCapabilities getCapabilities() { + @Nullable + public Set getCapabilities() { return capabilities; } @@ -130,11 +136,6 @@ public class AccountAttributes { return this; } - @VisibleForTesting - public void setPhoneNumberIdentityRegistrationId(final Integer phoneNumberIdentityRegistrationId) { - this.phoneNumberIdentityRegistrationId = phoneNumberIdentityRegistrationId; - } - @AssertTrue public boolean isEachRegistrationIdValid() { return validRegistrationId(registrationId) && validRegistrationId(phoneNumberIdentityRegistrationId); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java index c3414f628..b1eff26c5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java @@ -15,6 +15,7 @@ import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter; import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter; import java.util.List; +import java.util.Map; public class BaseProfileResponse { @@ -32,7 +33,7 @@ public class BaseProfileResponse { private boolean unrestrictedUnidentifiedAccess; @JsonProperty - private UserCapabilities capabilities; + private Map capabilities; @JsonProperty private List badges; @@ -48,7 +49,7 @@ public class BaseProfileResponse { public BaseProfileResponse(final IdentityKey identityKey, final byte[] unidentifiedAccess, final boolean unrestrictedUnidentifiedAccess, - final UserCapabilities capabilities, + final Map capabilities, final List badges, final ServiceIdentifier uuid) { @@ -72,7 +73,7 @@ public class BaseProfileResponse { return unrestrictedUnidentifiedAccess; } - public UserCapabilities getCapabilities() { + public Map getCapabilities() { return capabilities; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java deleted file mode 100644 index f3f74a662..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import org.whispersystems.textsecuregcm.storage.Account; - -public record UserCapabilities( - boolean deleteSync, - boolean versionedExpirationTimer) { - - public static UserCapabilities createForAccount(final Account account) { - return new UserCapabilities(account.isDeleteSyncSupported(), - account.isVersionedExpirationTimerSupported()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java index 9f10476a0..026ca493f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java @@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.grpc; import com.google.protobuf.ByteString; import io.grpc.Status; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; import org.signal.chat.device.ClearPushTokenRequest; @@ -27,6 +29,7 @@ import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -200,15 +203,21 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase { public Mono setCapabilities(final SetCapabilitiesRequest request) { final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + final Set capabilities = request.getCapabilitiesList().stream() + .map(capability -> switch (capability) { + case DEVICE_CAPABILITY_STORAGE -> DeviceCapability.STORAGE; + case DEVICE_CAPABILITY_TRANSFER -> DeviceCapability.TRANSFER; + case DEVICE_CAPABILITY_DELETE_SYNC -> DeviceCapability.DELETE_SYNC; + case DEVICE_CAPABILITY_VERSIONED_EXPIRATION_TIMER -> DeviceCapability.VERSIONED_EXPIRATION_TIMER; + default -> throw Status.INVALID_ARGUMENT.withDescription("Unrecognized device capability").asRuntimeException(); + }) + .collect(Collectors.toSet()); + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), - d -> d.setCapabilities(new Device.DeviceCapabilities( - request.getStorage(), - request.getTransfer(), - request.getDeleteSync(), - request.getVersionedExpirationTimer()))))) + d -> d.setCapabilities(capabilities)))) .thenReturn(SetCapabilitiesResponse.newBuilder().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 463ca4bc9..a71302e07 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -9,14 +9,15 @@ import com.google.common.annotations.VisibleForTesting; import com.google.protobuf.ByteString; import io.grpc.Status; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; +import org.signal.chat.profile.AccountCapabilities; import org.signal.chat.profile.Badge; import org.signal.chat.profile.BadgeSvg; import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileResponse; -import org.signal.chat.profile.UserCapabilities; import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.VerificationFailedException; @@ -24,9 +25,9 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialRespons import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; -import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.util.ProfileHelper; @@ -80,11 +81,21 @@ public class ProfileGrpcHelper { } @VisibleForTesting - static UserCapabilities buildUserCapabilities(final org.whispersystems.textsecuregcm.entities.UserCapabilities capabilities) { - return UserCapabilities.newBuilder() - .setDeleteSync(capabilities.deleteSync()) - .setVersionedExpirationTimer(capabilities.versionedExpirationTimer()) - .build(); + static AccountCapabilities buildAccountCapabilities(final Account account) { + final AccountCapabilities.Builder capabilitiesBuilder = AccountCapabilities.newBuilder(); + + Arrays.stream(DeviceCapability.values()) + .filter(DeviceCapability::includeInProfile) + .filter(account::hasCapability) + .map(capability -> switch (capability) { + case STORAGE -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_STORAGE; + case TRANSFER -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_TRANSFER; + case DELETE_SYNC -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_DELETE_SYNC; + case VERSIONED_EXPIRATION_TIMER -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_VERSIONED_EXPIRATION_TIMER; + }) + .forEach(capabilitiesBuilder::addCapabilities); + + return capabilitiesBuilder.build(); } private static List buildBadgeSvgs(final List badgeSvgs) { @@ -105,7 +116,7 @@ public class ProfileGrpcHelper { final ProfileBadgeConverter profileBadgeConverter) { final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder() .setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize())) - .setCapabilities(buildUserCapabilities(org.whispersystems.textsecuregcm.entities.UserCapabilities.createForAccount(targetAccount))); + .setCapabilities(buildAccountCapabilities(targetAccount)); switch (targetIdentifier.identityType()) { case ACI -> { @@ -113,7 +124,7 @@ public class ProfileGrpcHelper { .addAllBadges(buildBadges(profileBadgeConverter.convert( RequestAttributesUtil.getAvailableAcceptedLocales(), targetAccount.getBadges(), - ProfileHelper.isSelfProfileRequest(requesterUuid, (AciServiceIdentifier) targetIdentifier)))); + ProfileHelper.isSelfProfileRequest(requesterUuid, targetIdentifier)))); targetAccount.getUnidentifiedAccessKey() .map(UnidentifiedAccessChecksum::generateFor) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index 3c6f171f2..6111e0e53 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -18,7 +18,6 @@ import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; -import java.util.function.Predicate; import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; import org.signal.libsignal.protocol.IdentityKey; @@ -30,7 +29,6 @@ import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter; @@ -279,33 +277,14 @@ public class Account { return devices.stream().filter(device -> device.getId() == deviceId).findFirst(); } - public boolean isStorageSupported() { + public boolean hasCapability(final DeviceCapability capability) { requireNotStale(); - return devices.stream().anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().storage()); - } - - public boolean isTransferSupported() { - requireNotStale(); - - return Optional.ofNullable(getPrimaryDevice().getCapabilities()) - .map(DeviceCapabilities::transfer) - .orElse(false); - } - - public boolean isDeleteSyncSupported() { - return allDevicesHaveCapability(DeviceCapabilities::deleteSync); - } - - public boolean isVersionedExpirationTimerSupported() { - return allDevicesHaveCapability(DeviceCapabilities::versionedExpirationTimer); - } - - private boolean allDevicesHaveCapability(final Predicate predicate) { - requireNotStale(); - - return devices.stream() - .allMatch(device -> device.getCapabilities() != null && predicate.test(device.getCapabilities())); + return switch (capability.getAccountCapabilityMode()) { + case PRIMARY_DEVICE -> getPrimaryDevice().hasCapability(capability); + case ANY_DEVICE -> devices.stream().anyMatch(device -> device.hasCapability(capability)); + case ALL_DEVICES -> devices.stream().allMatch(device -> device.hasCapability(capability)); + }; } public byte getNextDeviceId() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java index 6cffa39ef..0d4e1533c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java @@ -9,12 +9,17 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; import java.util.List; import java.util.OptionalInt; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; import javax.annotation.Nullable; +import com.google.common.annotations.VisibleForTesting; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter; import org.whispersystems.textsecuregcm.util.DeviceNameByteArrayAdapter; public class Device { @@ -72,7 +77,9 @@ public class Device { private String userAgent; @JsonProperty - private DeviceCapabilities capabilities; + @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class) + @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class) + private Set capabilities = Collections.emptySet(); public String getApnId() { return apnId; @@ -166,13 +173,19 @@ public class Device { return new SaltedTokenHash(authToken, salt); } - @Nullable - public DeviceCapabilities getCapabilities() { + @VisibleForTesting + public Set getCapabilities() { return capabilities; } - public void setCapabilities(DeviceCapabilities capabilities) { - this.capabilities = capabilities; + public void setCapabilities(@Nullable final Set capabilities) { + this.capabilities = (capabilities == null || capabilities.isEmpty()) + ? Collections.emptySet() + : EnumSet.copyOf(capabilities); + } + + public boolean hasCapability(final DeviceCapability capability) { + return capabilities.contains(capability); } public boolean isExpired() { @@ -220,8 +233,4 @@ public class Device { public String getUserAgent() { return this.userAgent; } - - public record DeviceCapabilities(boolean storage, boolean transfer, boolean deleteSync, - boolean versionedExpirationTimer) { - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceCapability.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceCapability.java new file mode 100644 index 000000000..2f3ddc43c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceCapability.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +public enum DeviceCapability { + STORAGE("storage", AccountCapabilityMode.ANY_DEVICE, false, false), + TRANSFER("transfer", AccountCapabilityMode.PRIMARY_DEVICE, false, false), + DELETE_SYNC("deleteSync", AccountCapabilityMode.ALL_DEVICES, true, true), + VERSIONED_EXPIRATION_TIMER("versionedExpirationTimer", AccountCapabilityMode.ALL_DEVICES, true, true); + + public enum AccountCapabilityMode { + PRIMARY_DEVICE, + ANY_DEVICE, + ALL_DEVICES, + } + + private final String name; + private final AccountCapabilityMode accountCapabilityMode; + private final boolean preventDowngrade; + private final boolean includeInProfile; + + DeviceCapability(final String name, + final AccountCapabilityMode accountCapabilityMode, + final boolean preventDowngrade, + final boolean includeInProfile) { + + this.name = name; + this.accountCapabilityMode = accountCapabilityMode; + this.preventDowngrade = preventDowngrade; + this.includeInProfile = includeInProfile; + } + + public String getName() { + return name; + } + + public AccountCapabilityMode getAccountCapabilityMode() { + return accountCapabilityMode; + } + + public boolean preventDowngrade() { + return preventDowngrade; + } + + public boolean includeInProfile() { + return includeInProfile; + } + + public static DeviceCapability forName(final String name) { + for (final DeviceCapability capability : DeviceCapability.values()) { + if (capability.getName().equals(name)) { + return capability; + } + } + + throw new IllegalArgumentException("Unknown capability: " + name); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java index 0cd3f9619..016e9bde4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java @@ -1,21 +1,22 @@ package org.whispersystems.textsecuregcm.storage; +import java.time.Clock; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.util.Util; -import java.time.Clock; -import java.util.Arrays; -import java.util.Objects; -import java.util.Optional; public record DeviceSpec( byte[] deviceNameCiphertext, String password, String signalAgent, - Device.DeviceCapabilities capabilities, + Set capabilities, int aciRegistrationId, int pniRegistrationId, boolean fetchesMessages, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapter.java new file mode 100644 index 000000000..d773ec3cf --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapter.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; +import java.io.IOException; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class DeviceCapabilityAdapter { + + private static final TypeReference> STRING_TO_BOOLEAN_MAP_TYPE = new TypeReference<>() {}; + + private DeviceCapabilityAdapter() { + } + + public static class Serializer extends JsonSerializer> { + + @Override + public void serialize(final Set capabilities, + final JsonGenerator jsonGenerator, + final SerializerProvider serializerProvider) throws IOException { + + jsonGenerator.writeObject(capabilities.stream() + .map(DeviceCapability::getName) + .collect(Collectors.toMap(capability -> capability, ignored -> true))); + } + } + + public static class Deserializer extends JsonDeserializer> { + + @Override + public Set deserialize(final JsonParser jsonParser, + final DeserializationContext deserializationContext) throws IOException { + + final Map capabilitiesMap = jsonParser.readValueAs(STRING_TO_BOOLEAN_MAP_TYPE); + final EnumSet capabilities = EnumSet.noneOf(DeviceCapability.class); + + capabilitiesMap.forEach((capability, active) -> { + if (active) { + try { + capabilities.add(DeviceCapability.forName(capability)); + } catch (final IllegalArgumentException ignored) { + // This most likely means we've retired a capability + } + } + }); + + return capabilities; + } + } +} diff --git a/service/src/main/proto/org/signal/chat/common.proto b/service/src/main/proto/org/signal/chat/common.proto index f0ce90de5..c8245a1c3 100644 --- a/service/src/main/proto/org/signal/chat/common.proto +++ b/service/src/main/proto/org/signal/chat/common.proto @@ -92,3 +92,11 @@ message KemSignedPreKey { */ bytes signature = 3; } + +enum DeviceCapability { + DEVICE_CAPABILITY_UNSPECIFIED = 0; + DEVICE_CAPABILITY_STORAGE = 1; + DEVICE_CAPABILITY_TRANSFER = 2; + DEVICE_CAPABILITY_DELETE_SYNC = 3; + DEVICE_CAPABILITY_VERSIONED_EXPIRATION_TIMER = 4; +} diff --git a/service/src/main/proto/org/signal/chat/device.proto b/service/src/main/proto/org/signal/chat/device.proto index 9eeb7fdf9..d0ca4affc 100644 --- a/service/src/main/proto/org/signal/chat/device.proto +++ b/service/src/main/proto/org/signal/chat/device.proto @@ -9,6 +9,8 @@ option java_multiple_files = true; package org.signal.chat.device; +import "org/signal/chat/common.proto"; + /** * Provides methods for working with devices attached to a Signal account. */ @@ -151,10 +153,7 @@ message ClearPushTokenRequest {} message ClearPushTokenResponse {} message SetCapabilitiesRequest { - bool storage = 1; - bool transfer = 2; - bool deleteSync = 3; - bool versionedExpirationTimer = 4; + repeated common.DeviceCapability capabilities = 1; } message SetCapabilitiesResponse {} diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index af44fd621..59577524a 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -241,7 +241,7 @@ message GetUnversionedProfileResponse { /** * A list of capabilities enabled on the account. */ - UserCapabilities capabilities = 4; + AccountCapabilities capabilities = 4; /** * A list of badges associated with the account. */ @@ -317,15 +317,8 @@ message ProfileAvatarUploadAttributes { bytes signature = 7; } -message UserCapabilities { - /** - * Whether all devices linked to the account support delete syncing - */ - bool delete_sync = 1; - /** - * Whether all devices linked to the account support a versioned expiration timer - */ - bool versioned_expiration_timer = 2; +message AccountCapabilities { + repeated common.DeviceCapability capabilities = 1; } message Badge { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index ff749819e..ba2ad0525 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -84,6 +84,7 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; @@ -185,7 +186,7 @@ class AccountControllerTest { new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis()))); when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID()); - when(senderHasStorage.isStorageSupported()).thenReturn(true); + when(senderHasStorage.hasCapability(DeviceCapability.STORAGE)).thenReturn(true); when(senderHasStorage.getRegistrationLock()).thenReturn( new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis()))); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java index 01f7f9eb7..c44dd3407 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java @@ -30,10 +30,12 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; +import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -52,6 +54,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.jupiter.cartesian.CartesianTest; import org.mockito.ArgumentCaptor; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.ecc.Curve; @@ -62,13 +65,13 @@ import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest; import org.whispersystems.textsecuregcm.entities.DeviceInfo; -import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest; -import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest; +import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse; import org.whispersystems.textsecuregcm.entities.RemoteAttachment; +import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest; import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest; import org.whispersystems.textsecuregcm.identity.IdentityType; @@ -81,17 +84,17 @@ import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager; import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.tests.util.MockRedisFuture; +import org.whispersystems.textsecuregcm.util.LinkDeviceToken; import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; -import org.whispersystems.textsecuregcm.util.LinkDeviceToken; @ExtendWith(DropwizardExtensionsSupport.class) class DeviceControllerTest { @@ -216,7 +219,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final AccountAttributes accountAttributes = new AccountAttributes(fetchesMessages, 1234, 5678, null, - null, true, new DeviceCapabilities(true, true, false, false)); + null, true, Set.of()); final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", accountAttributes, @@ -256,64 +259,11 @@ class DeviceControllerTest { ); } - @ParameterizedTest - @MethodSource - void deviceDowngradeDeleteSync(final boolean accountSupportsDeleteSync, final boolean deviceSupportsDeleteSync, final int expectedStatus) { - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(accountsManager.addDevice(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class)))); + @CartesianTest + void deviceDowngrade(@CartesianTest.Enum final DeviceCapability capability, + @CartesianTest.Values(booleans = {true, false}) final boolean accountHasCapability, + @CartesianTest.Values(booleans = {true, false}) final boolean requestHasCapability) { - final Device primaryDevice = mock(Device.class); - when(primaryDevice.getId()).thenReturn(Device.PRIMARY_ID); - when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(primaryDevice)); - - final ECSignedPreKey aciSignedPreKey; - final ECSignedPreKey pniSignedPreKey; - final KEMSignedPreKey aciPqLastResortPreKey; - final KEMSignedPreKey pniPqLastResortPreKey; - - final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair(); - final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair(); - - aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair); - pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair); - aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair); - pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair); - - when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); - when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - when(account.isDeleteSyncSupported()).thenReturn(accountSupportsDeleteSync); - - when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); - - when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); - - final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", - new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, deviceSupportsDeleteSync, false)), - new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); - - try (final Response response = resources.getJerseyTest() - .target("/v1/devices/link") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) - .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) { - - assertEquals(expectedStatus, response.getStatus()); - } - } - - private static List deviceDowngradeDeleteSync() { - return List.of( - Arguments.of(true, true, 200), - Arguments.of(true, false, 409), - Arguments.of(false, true, 200), - Arguments.of(false, false, 200)); - } - - @ParameterizedTest - @MethodSource - void deviceDowngradeVersionedExpirationTimer(final boolean accountSupportsVersionedExpirationTimer, - final boolean deviceSupportsVersionedExpirationTimer, final int expectedStatus) { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); when(accountsManager.addDevice(any(), any(), any())) .thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class)))); @@ -337,16 +287,25 @@ class DeviceControllerTest { when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - when(account.isDeleteSyncSupported()).thenReturn(accountSupportsVersionedExpirationTimer); + when(account.hasCapability(capability)).thenReturn(accountHasCapability); when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + final Set requestCapabilities = EnumSet.allOf(DeviceCapability.class); + + if (!requestHasCapability) { + requestCapabilities.remove(capability); + } + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", - new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, deviceSupportsVersionedExpirationTimer, false)), + new AccountAttributes(false, 1234, 5678, null, null, true, requestCapabilities), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); + final int expectedStatus = + capability.preventDowngrade() && accountHasCapability && !requestHasCapability ? 409 : 200; + try (final Response response = resources.getJerseyTest() .target("/v1/devices/link") .request() @@ -357,14 +316,6 @@ class DeviceControllerTest { } } - private static List deviceDowngradeVersionedExpirationTimer() { - return List.of( - Arguments.of(true, true, 200), - Arguments.of(true, false, 409), - Arguments.of(false, true, 200), - Arguments.of(false, false, 200)); - } - @Test void linkDeviceAtomicBadCredentials() { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); @@ -433,7 +384,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final AccountAttributes accountAttributes = new AccountAttributes(true, 1234, 5678, null, - null, true, new DeviceCapabilities(true, true, false, false)); + null, true, Set.of()); final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", accountAttributes, @@ -769,7 +720,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", - new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, new DeviceCapabilities(true, true, false, false)), + new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, Set.of()), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.of(new ApnRegistrationId("apn")), Optional.empty())); try (final Response response = resources.getJerseyTest() @@ -828,14 +779,13 @@ class DeviceControllerTest { @Test void putCapabilitiesSuccessTest() { - final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, false, false); try (final Response response = resources .getJerseyTest() .target("/v1/devices/capabilities") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(deviceCapabilities, MediaType.APPLICATION_JSON_TYPE))) { + .put(Entity.entity(Set.of(), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(204); assertThat(response.hasEntity()).isFalse(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java index 04cd94b25..368e9917f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -100,6 +100,7 @@ import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; @@ -443,16 +444,16 @@ class ProfileControllerTest { void testProfileCapabilities( @CartesianTest.Values(booleans = {true, false}) final boolean isDeleteSyncSupported, @CartesianTest.Values(booleans = {true, false}) final boolean isVersionedExpirationTimerSupported) { - when(capabilitiesAccount.isDeleteSyncSupported()).thenReturn(isDeleteSyncSupported); - when(capabilitiesAccount.isVersionedExpirationTimerSupported()).thenReturn(isVersionedExpirationTimerSupported); + when(capabilitiesAccount.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(isDeleteSyncSupported); + when(capabilitiesAccount.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(isVersionedExpirationTimerSupported); final BaseProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID) .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .get(BaseProfileResponse.class); - assertEquals(isDeleteSyncSupported, profile.getCapabilities().deleteSync()); - assertEquals(isVersionedExpirationTimerSupported, profile.getCapabilities().versionedExpirationTimer()); + assertEquals(isDeleteSyncSupported, profile.getCapabilities().get(DeviceCapability.DELETE_SYNC.name())); + assertEquals(isVersionedExpirationTimerSupported, profile.getCapabilities().get(DeviceCapability.VERSIONED_EXPIRATION_TIMER.name())); } @Test diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java index 73fbf05b0..e0c32033f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java @@ -74,8 +74,9 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; +import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; @@ -309,7 +310,7 @@ class RegistrationControllerTest { } @Test - void recoveryPasswordManagerVerificationFalse() throws InterruptedException { + void recoveryPasswordManagerVerificationFalse() { when(registrationRecoveryPasswordsManager.verify(any(), any())) .thenReturn(CompletableFuture.completedFuture(false)); @@ -380,7 +381,7 @@ class RegistrationControllerTest { final Account account = mock(Account.class); when(accountsManager.getByE164(any())).thenReturn(Optional.of(account)); - when(account.isTransferSupported()).thenReturn(deviceTransferSupported); + when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(deviceTransferSupported); final int expectedStatus; if (deviceTransferSupported) { @@ -441,7 +442,7 @@ class RegistrationControllerTest { final Optional maybeAccount; if (existingAccount) { final Account account = mock(Account.class); - when(account.isTransferSupported()).thenReturn(transferSupported); + when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(transferSupported); maybeAccount = Optional.of(account); } else { maybeAccount = Optional.empty(); @@ -526,10 +527,10 @@ class RegistrationControllerTest { } final AccountAttributes fetchesMessagesAccountAttributes = - new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, Set.of()); final AccountAttributes pushAccountAttributes = - new AccountAttributes(false, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(false, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, Set.of()); return Stream.of( // "Fetches messages" is true, but an APNs token is provided @@ -615,7 +616,7 @@ class RegistrationControllerTest { } final AccountAttributes accountAttributes = - new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, Set.of()); return Stream.of( // Signed PNI EC pre-key is missing @@ -786,13 +787,13 @@ class RegistrationControllerTest { final int registrationId = 1; final int pniRegistrationId = 2; - final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(false, false, false, false); + final Set deviceCapabilities = Set.of(); final AccountAttributes fetchesMessagesAccountAttributes = - new AccountAttributes(true, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(true, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities); final AccountAttributes pushAccountAttributes = - new AccountAttributes(false, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(false, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities); final String apnsToken = "apns-token"; final String gcmToken = "gcm-token"; @@ -906,7 +907,7 @@ class RegistrationControllerTest { final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey()); final AccountAttributes accountAttributes = new AccountAttributes(true, registrationId, pniRegistrationId, "name".getBytes(StandardCharsets.UTF_8), "reglock", - true, new Device.DeviceCapabilities(true, true, false, false)); + true, Set.of()); final RegistrationRequest request = new RegistrationRequest( Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java index 4fc8d0bcd..845a2807f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java @@ -20,8 +20,10 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.stream.Stream; @@ -49,6 +51,7 @@ import org.signal.chat.device.SetPushTokenResponse; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.util.TestRandomUtil; class DevicesGrpcServiceTest extends SimpleBaseGrpcTest { @@ -439,18 +442,43 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest expectedCapabilities = new HashSet<>(); + + if (storage) { + expectedCapabilities.add(DeviceCapability.STORAGE); + } + + if (transfer) { + expectedCapabilities.add(DeviceCapability.TRANSFER); + } + + if (deleteSync) { + expectedCapabilities.add(DeviceCapability.DELETE_SYNC); + } + + if (versionedExpirationTimer) { + expectedCapabilities.add(DeviceCapability.VERSIONED_EXPIRATION_TIMER); + } verify(device).setCapabilities(expectedCapabilities); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java index 92c52390e..1d3a8d42b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java @@ -6,7 +6,6 @@ package org.whispersystems.textsecuregcm.grpc; import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -37,6 +36,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.signal.chat.common.IdentityType; import org.signal.chat.common.ServiceIdentifier; +import org.signal.chat.profile.AccountCapabilities; import org.signal.chat.profile.CredentialType; import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest; import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; @@ -53,7 +53,6 @@ import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.ServerPublicParams; import org.signal.libsignal.zkgroup.ServerSecretParams; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; @@ -62,21 +61,19 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext; -import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.entities.UserCapabilities; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; -import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; @@ -143,6 +140,8 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest deviceCapabilities = Set.of(); final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(), registrationId, pniRegistrationId, deviceName, registrationLockSecret, - discoverableByPhoneNumber, deviceCapabilities); + discoverableByPhoneNumber, + deviceCapabilities); final List badges = new ArrayList<>(List.of(new AccountBadge( RandomStringUtils.randomAlphabetic(8), @@ -303,15 +301,14 @@ public class AccountCreationDeletionIntegrationTest { final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair); final Account originalAccount = accountsManager.create(number, - new AccountAttributes(true, 1, 1, "name".getBytes(StandardCharsets.UTF_8), "registration-lock", false, - new Device.DeviceCapabilities(false, false, false, false)), + new AccountAttributes(true, 1, 1, "name".getBytes(StandardCharsets.UTF_8), "registration-lock", false, Set.of()), Collections.emptyList(), new IdentityKey(aciKeyPair.getPublicKey()), new IdentityKey(pniKeyPair.getPublicKey()), new DeviceSpec(null, "password?", "OWI", - new Device.DeviceCapabilities(false, false, false, false), + Set.of(), 1, 2, true, @@ -333,11 +330,7 @@ public class AccountCreationDeletionIntegrationTest { final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8); final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16); - final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities( - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean()); + final Set deviceCapabilities = Set.of(); final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(), registrationId, @@ -424,11 +417,7 @@ public class AccountCreationDeletionIntegrationTest { final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8); final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16); - final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities( - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean(), - ThreadLocalRandom.current().nextBoolean()); + final Set deviceCapabilities = Set.of(); final AccountAttributes accountAttributes = new AccountAttributes(true, registrationId, @@ -498,7 +487,7 @@ public class AccountCreationDeletionIntegrationTest { final int pniRegistrationId, final byte[] deviceName, final boolean discoverableByPhoneNumber, - final Device.DeviceCapabilities deviceCapabilities, + final Set deviceCapabilities, final List badges, final Optional maybeApnRegistrationId, final Optional maybeGcmRegistrationId, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountTest.java index 7403c4ff7..a6c7884f2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountTest.java @@ -29,7 +29,6 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.util.TestClock; @@ -64,21 +63,16 @@ class AccountTest { when(oldSecondaryDevice.getId()).thenReturn(deviceId2); when(deleteSyncCapableDevice.getId()).thenReturn((byte) 1); - when(deleteSyncCapableDevice.getCapabilities()) - .thenReturn(new DeviceCapabilities(true, true, true, false)); + when(deleteSyncCapableDevice.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(true); when(deleteSyncIncapableDevice.getId()).thenReturn((byte) 2); - when(deleteSyncIncapableDevice.getCapabilities()) - .thenReturn(new DeviceCapabilities(true, true, false, false)); + when(deleteSyncIncapableDevice.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(false); when(versionedExpirationTimerCapableDevice.getId()).thenReturn((byte) 1); - when(versionedExpirationTimerCapableDevice.getCapabilities()) - .thenReturn(new DeviceCapabilities(true, true, false, true)); + when(versionedExpirationTimerCapableDevice.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(true); when(versionedExpirationTimerIncapableDevice.getId()).thenReturn((byte) 2); - when(versionedExpirationTimerIncapableDevice.getCapabilities()) - .thenReturn(new DeviceCapabilities(true, true, false, false)); - + when(versionedExpirationTimerIncapableDevice.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(false); } @Test @@ -87,42 +81,36 @@ class AccountTest { final Device nonTransferCapablePrimaryDevice = mock(Device.class); final Device transferCapableLinkedDevice = mock(Device.class); - final DeviceCapabilities transferCapabilities = mock(DeviceCapabilities.class); - final DeviceCapabilities nonTransferCapabilities = mock(DeviceCapabilities.class); - when(transferCapablePrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID); when(transferCapablePrimaryDevice.isPrimary()).thenReturn(true); - when(transferCapablePrimaryDevice.getCapabilities()).thenReturn(transferCapabilities); + when(transferCapablePrimaryDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(true); when(nonTransferCapablePrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID); when(nonTransferCapablePrimaryDevice.isPrimary()).thenReturn(true); - when(nonTransferCapablePrimaryDevice.getCapabilities()).thenReturn(nonTransferCapabilities); + when(nonTransferCapablePrimaryDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(false); when(transferCapableLinkedDevice.getId()).thenReturn((byte) 2); when(transferCapableLinkedDevice.isPrimary()).thenReturn(false); - when(transferCapableLinkedDevice.getCapabilities()).thenReturn(transferCapabilities); - - when(transferCapabilities.transfer()).thenReturn(true); - when(nonTransferCapabilities.transfer()).thenReturn(false); + when(transferCapableLinkedDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(true); { final Account transferablePrimaryAccount = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(transferCapablePrimaryDevice), "1234".getBytes()); - assertTrue(transferablePrimaryAccount.isTransferSupported()); + assertTrue(transferablePrimaryAccount.hasCapability(DeviceCapability.TRANSFER)); } { final Account nonTransferablePrimaryAccount = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapablePrimaryDevice), "1234".getBytes()); - assertFalse(nonTransferablePrimaryAccount.isTransferSupported()); + assertFalse(nonTransferablePrimaryAccount.hasCapability(DeviceCapability.TRANSFER)); } { final Account transferableLinkedAccount = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapablePrimaryDevice, transferCapableLinkedDevice), "1234".getBytes()); - assertFalse(transferableLinkedAccount.isTransferSupported()); + assertFalse(transferableLinkedAccount.hasCapability(DeviceCapability.TRANSFER)); } } @@ -145,20 +133,20 @@ class AccountTest { void isDeleteSyncSupported() { assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), List.of(deleteSyncCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isDeleteSyncSupported()); + "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.DELETE_SYNC)); assertFalse(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), List.of(deleteSyncIncapableDevice, deleteSyncCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isDeleteSyncSupported()); + "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.DELETE_SYNC)); } @Test void isVersionedExpirationTimerSupported() { assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), List.of(versionedExpirationTimerCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isVersionedExpirationTimerSupported()); + "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)); assertFalse(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), List.of(versionedExpirationTimerIncapableDevice, versionedExpirationTimerCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isVersionedExpirationTimerSupported()); + "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)); } @Test @@ -248,7 +236,7 @@ class AccountTest { } @Test - public void testAccountClassJsonFilterIdMatchesClassName() throws Exception { + public void testAccountClassJsonFilterIdMatchesClassName() { // Some logic relies on the @JsonFilter name being equal to the class name. // This test is just making sure that annotation is there and that the ID matches class name. final Optional maybeJsonFilterAnnotation = Arrays.stream(Account.class.getAnnotations()) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java index f6da8755c..a9fcfc94a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java @@ -18,6 +18,7 @@ import java.time.Clock; import java.util.Map; import java.util.Optional; import java.util.OptionalInt; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -198,7 +199,7 @@ class AccountsManagerChangeNumberIntegrationTest { final int rotatedPniRegistrationId = 17; final ECKeyPair rotatedPniIdentityKeyPair = Curve.generateKeyPair(); final ECSignedPreKey rotatedSignedPreKey = KeysHelper.signedECPreKey(1L, rotatedPniIdentityKeyPair); - final AccountAttributes accountAttributes = new AccountAttributes(true, rotatedPniRegistrationId + 1, rotatedPniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + final AccountAttributes accountAttributes = new AccountAttributes(true, rotatedPniRegistrationId + 1, rotatedPniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, Set.of()); final Account account = AccountsHelper.createAccount(accountsManager, originalNumber, accountAttributes); keysManager.storeEcSignedPreKeys(account.getIdentifier(IdentityType.ACI), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java index 6801948d9..a8d15bce6 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java @@ -25,6 +25,7 @@ import java.time.Clock; import java.time.Instant; import java.util.ArrayList; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -161,7 +162,7 @@ class AccountsManagerConcurrentModificationIntegrationTest { null, "password", null, - new Device.DeviceCapabilities(false, false, false, false), + Set.of(), 1, 2, true, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java index f2477daaa..d92233584 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java @@ -49,6 +49,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -57,6 +58,7 @@ import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -78,13 +80,12 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient; +import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient; import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecovery2Client; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryException; import org.whispersystems.textsecuregcm.storage.AccountsManager.UsernameReservation; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; @@ -95,7 +96,6 @@ import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; -import javax.crypto.spec.SecretKeySpec; @Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) class AccountsManagerTest { @@ -930,11 +930,11 @@ class AccountsManagerTest { @ValueSource(booleans = {true, false}) void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException { final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, - true, new DeviceCapabilities(hasStorage, false, false, false)); + true, hasStorage ? Set.of(DeviceCapability.STORAGE) : Set.of()); final Account account = createAccount("+18005550123", attributes); - assertEquals(hasStorage, account.isStorageSupported()); + assertEquals(hasStorage, account.hasCapability(DeviceCapability.STORAGE)); } @Test @@ -955,7 +955,7 @@ class AccountsManagerTest { final byte[] deviceNameCiphertext = "device-name".getBytes(StandardCharsets.UTF_8); final String password = "password"; final String signalAgent = "OWT"; - final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, false, false); + final Set deviceCapabilities = Set.of(); final int aciRegistrationId = 17; final int pniRegistrationId = 19; final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair); @@ -1005,7 +1005,7 @@ class AccountsManagerTest { assertEquals(deviceNameCiphertext, device.getName()); assertTrue(device.getAuthTokenHash().verify(password)); assertEquals(signalAgent, device.getUserAgent()); - assertEquals(deviceCapabilities, device.getCapabilities()); + assertEquals(Collections.emptySet(), device.getCapabilities()); assertEquals(aciRegistrationId, device.getRegistrationId()); assertEquals(pniRegistrationId, device.getPhoneNumberIdentityRegistrationId().getAsInt()); assertTrue(device.getFetchesMessages()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java index cac7a31d4..e05cfcaa0 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java @@ -17,6 +17,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -190,19 +191,19 @@ public class AddRemoveDeviceIntegrationTest { final Pair updatedAccountAndDevice = accountsManager.addDevice(account, new DeviceSpec( - "device-name".getBytes(StandardCharsets.UTF_8), - "password", - "OWT", - new Device.DeviceCapabilities(true, true, false, false), - 1, - 2, - true, - Optional.empty(), - Optional.empty(), - KeysHelper.signedECPreKey(1, aciKeyPair), - KeysHelper.signedECPreKey(2, pniKeyPair), - KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair)), + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + Set.of(), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) .join(); @@ -239,7 +240,7 @@ public class AddRemoveDeviceIntegrationTest { "device-name".getBytes(StandardCharsets.UTF_8), "password", "OWT", - new Device.DeviceCapabilities(true, true, false, false), + Set.of(), 1, 2, true, @@ -258,21 +259,21 @@ public class AddRemoveDeviceIntegrationTest { final CompletionException completionException = assertThrows(CompletionException.class, () -> accountsManager.addDevice(account, new DeviceSpec( - "device-name".getBytes(StandardCharsets.UTF_8), - "password", - "OWT", - new Device.DeviceCapabilities(true, true, false, false), - 1, - 2, - true, - Optional.empty(), - Optional.empty(), - KeysHelper.signedECPreKey(1, aciKeyPair), - KeysHelper.signedECPreKey(2, pniKeyPair), - KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair)), - linkDeviceToken) - .join()); + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + Set.of(), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + linkDeviceToken) + .join()); assertInstanceOf(LinkDeviceTokenAlreadyUsedException.class, completionException.getCause()); @@ -295,19 +296,19 @@ public class AddRemoveDeviceIntegrationTest { final Pair updatedAccountAndDevice = accountsManager.addDevice(account, new DeviceSpec( - "device-name".getBytes(StandardCharsets.UTF_8), - "password", - "OWT", - new Device.DeviceCapabilities(true, true, false, false), - 1, - 2, - true, - Optional.empty(), - Optional.empty(), - KeysHelper.signedECPreKey(1, aciKeyPair), - KeysHelper.signedECPreKey(2, pniKeyPair), - KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair)), + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + Set.of(), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) .join(); @@ -349,19 +350,19 @@ public class AddRemoveDeviceIntegrationTest { final Pair updatedAccountAndDevice = accountsManager.addDevice(account, new DeviceSpec( - "device-name".getBytes(StandardCharsets.UTF_8), - "password", - "OWT", - new Device.DeviceCapabilities(true, true, false, false), - 1, - 2, - true, - Optional.empty(), - Optional.empty(), - KeysHelper.signedECPreKey(1, aciKeyPair), - KeysHelper.signedECPreKey(2, pniKeyPair), - KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair)), + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + Set.of(), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) .join(); @@ -420,7 +421,7 @@ public class AddRemoveDeviceIntegrationTest { "device-name".getBytes(StandardCharsets.UTF_8), "password", "OWT", - new Device.DeviceCapabilities(true, true, true, false), + Set.of(), 1, 2, true, @@ -461,7 +462,7 @@ public class AddRemoveDeviceIntegrationTest { "device-name".getBytes(StandardCharsets.UTF_8), "password", "OWT", - new Device.DeviceCapabilities(true, true, true, false), + Set.of(), 1, 2, true, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java index 0075b4876..69167be67 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java @@ -147,8 +147,7 @@ public class AccountsHelper { case "getPrimaryDevice" -> when(updatedAccount.getPrimaryDevice()).thenAnswer(stubbing); case "isDiscoverableByPhoneNumber" -> when(updatedAccount.isDiscoverableByPhoneNumber()).thenAnswer(stubbing); case "getNextDeviceId" -> when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing); - case "isDeleteSyncSupported" -> when(updatedAccount.isDeleteSyncSupported()).thenAnswer(stubbing); - case "isVersionedExpirationTimerSupported" -> when(updatedAccount.isVersionedExpirationTimerSupported()).thenAnswer(stubbing); + case "hasCapability" -> when(updatedAccount.hasCapability(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing); case "getRegistrationLock" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing); case "getIdentityKey" -> when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapterTest.java new file mode 100644 index 000000000..98998cde2 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapterTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.EnumSet; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; + +class DeviceCapabilityAdapterTest { + + private record TestObject( + @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class) + @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class) + @Nullable + EnumSet capabilities) { + } + + @Test + void serializeDeserialize() throws JsonProcessingException { + { + final TestObject testObject = new TestObject(EnumSet.of(DeviceCapability.TRANSFER, DeviceCapability.STORAGE)); + final String json = SystemMapper.jsonMapper().writeValueAsString(testObject); + + assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class)); + } + + { + final TestObject testObject = new TestObject(EnumSet.noneOf(DeviceCapability.class)); + final String json = SystemMapper.jsonMapper().writeValueAsString(testObject); + + assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class)); + } + + { + final TestObject testObject = new TestObject(null); + final String json = SystemMapper.jsonMapper().writeValueAsString(testObject); + + assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class)); + } + + { + final String json = """ + { + "capabilities": { + "transfer": true, + "unrecognizedCapability": true + } + } + """; + + assertEquals(new TestObject(EnumSet.of(DeviceCapability.TRANSFER)), + SystemMapper.jsonMapper().readValue(json, TestObject.class)); + } + + { + final String json = """ + { + "capabilities": { + "transfer": true, + "deleteSync": false + } + } + """; + + assertEquals(new TestObject(EnumSet.of(DeviceCapability.TRANSFER)), + SystemMapper.jsonMapper().readValue(json, TestObject.class)); + } + } +}