Replace `DeviceCapabilities` entity with `Set<DeviceCapability>`

This commit is contained in:
Jon Chambers 2024-10-30 12:46:20 -04:00 committed by GitHub
parent b21b50873f
commit 0e3dccd9f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 532 additions and 348 deletions

View File

@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
@ -127,7 +128,7 @@ public class TestUser {
} }
public AccountAttributes accountAttributes() { 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) .withUnidentifiedAccessKey(unidentifiedAccessKey)
.withRecoveryPassword(registrationPassword); .withRecoveryPassword(registrationPassword);
} }

View File

@ -60,6 +60,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
@ -282,7 +283,7 @@ public class AccountController {
auth.getAccount().getPhoneNumberIdentifier(), auth.getAccount().getPhoneNumberIdentifier(),
auth.getAccount().getUsernameHash().filter(h -> h.length > 0).orElse(null), auth.getAccount().getUsernameHash().filter(h -> h.length > 0).orElse(null),
auth.getAccount().getUsernameLinkHandle(), auth.getAccount().getUsernameLinkHandle(),
auth.getAccount().isStorageSupported()); auth.getAccount().hasCapability(DeviceCapability.STORAGE));
} }
@DELETE @DELETE

View File

@ -54,6 +54,7 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.websocket.auth.Mutable; import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly; import org.whispersystems.websocket.auth.ReadOnly;
@ -151,7 +152,7 @@ public class AccountControllerV2 {
updatedAccount.getPhoneNumberIdentifier(), updatedAccount.getPhoneNumberIdentifier(),
updatedAccount.getUsernameHash().orElse(null), updatedAccount.getUsernameHash().orElse(null),
updatedAccount.getUsernameLinkHandle(), updatedAccount.getUsernameLinkHandle(),
updatedAccount.isStorageSupported()); updatedAccount.hasCapability(DeviceCapability.STORAGE));
} catch (MismatchedDevicesException e) { } catch (MismatchedDevicesException e) {
throw new WebApplicationException(Response.status(409) throw new WebApplicationException(Response.status(409)
.type(MediaType.APPLICATION_JSON_TYPE) .type(MediaType.APPLICATION_JSON_TYPE)
@ -210,7 +211,7 @@ public class AccountControllerV2 {
updatedAccount.getPhoneNumberIdentifier(), updatedAccount.getPhoneNumberIdentifier(),
updatedAccount.getUsernameHash().orElse(null), updatedAccount.getUsernameHash().orElse(null),
updatedAccount.getUsernameLinkHandle(), updatedAccount.getUsernameLinkHandle(),
updatedAccount.isStorageSupported()); updatedAccount.hasCapability(DeviceCapability.STORAGE));
} catch (MismatchedDevicesException e) { } catch (MismatchedDevicesException e) {
throw new WebApplicationException(Response.status(409) throw new WebApplicationException(Response.status(409)
.type(MediaType.APPLICATION_JSON_TYPE) .type(MediaType.APPLICATION_JSON_TYPE)

View File

@ -4,6 +4,8 @@
*/ */
package org.whispersystems.textsecuregcm.controllers; 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.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders; import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.Auth; 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.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.Max; 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.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.glassfish.jersey.server.ContainerRequest; import org.glassfish.jersey.server.ContainerRequest;
import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices; import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;
import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider;
import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest; import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
import org.whispersystems.textsecuregcm.entities.DeviceInfo; import org.whispersystems.textsecuregcm.entities.DeviceInfo;
import org.whispersystems.textsecuregcm.entities.DeviceInfoList; 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.LinkDeviceRequest;
import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse;
import org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator; import org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator;
import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
import org.whispersystems.textsecuregcm.entities.RemoteAttachment; import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;
import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest;
import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest; import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest;
import org.whispersystems.textsecuregcm.identity.IdentityType; 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.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager; import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
import org.whispersystems.textsecuregcm.storage.Device; 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.DeviceSpec;
import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException; import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException;
import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
import org.whispersystems.textsecuregcm.util.EnumMapUtil; import org.whispersystems.textsecuregcm.util.EnumMapUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.LinkDeviceToken; import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
@ -270,7 +276,7 @@ public class DeviceController {
throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit); throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);
} }
final DeviceCapabilities capabilities = accountAttributes.getCapabilities(); final Set<DeviceCapability> capabilities = accountAttributes.getCapabilities();
if (capabilities == null) { if (capabilities == null) {
throw new WebApplicationException(Response.status(422, "Missing device capabilities").build()); throw new WebApplicationException(Response.status(422, "Missing device capabilities").build());
@ -405,7 +411,13 @@ public class DeviceController {
@PUT @PUT
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/capabilities") @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<DeviceCapability> capabilities) {
assert (auth.getAuthenticatedDevice() != null); assert (auth.getAuthenticatedDevice() != null);
final byte deviceId = auth.getAuthenticatedDevice().getId(); final byte deviceId = auth.getAuthenticatedDevice().getId();
accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities)); accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities));
@ -433,11 +445,13 @@ public class DeviceController {
setPublicKeyRequest.publicKey()); setPublicKeyRequest.publicKey());
} }
private static boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) { private static boolean isCapabilityDowngrade(final Account account, final Set<DeviceCapability> capabilities) {
boolean isDowngrade = false; final Set<DeviceCapability> requiredCapabilities = Arrays.stream(DeviceCapability.values())
isDowngrade |= account.isDeleteSyncSupported() && !capabilities.deleteSync(); .filter(DeviceCapability::preventDowngrade)
isDowngrade |= account.isVersionedExpirationTimerSupported() && !capabilities.versionedExpirationTimer(); .filter(account::hasCapability)
return isDowngrade; .collect(Collectors.toSet());
return !capabilities.containsAll(requiredCapabilities);
} }
@PUT @PUT

View File

@ -73,7 +73,6 @@ import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType; 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.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
@ -431,7 +431,7 @@ public class ProfileController {
return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI), return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI),
account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null), account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null),
account.isUnrestrictedUnidentifiedAccess(), account.isUnrestrictedUnidentifiedAccess(),
UserCapabilities.createForAccount(account), getAccountCapabilities(account),
profileBadgeConverter.convert( profileBadgeConverter.convert(
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext), HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext),
account.getBadges(), account.getBadges(),
@ -443,7 +443,7 @@ public class ProfileController {
return new BaseProfileResponse(account.getIdentityKey(IdentityType.PNI), return new BaseProfileResponse(account.getIdentityKey(IdentityType.PNI),
null, null,
false, false,
UserCapabilities.createForAccount(account), getAccountCapabilities(account),
Collections.emptyList(), Collections.emptyList(),
new PniServiceIdentifier(account.getPhoneNumberIdentifier())); new PniServiceIdentifier(account.getPhoneNumberIdentifier()));
} }
@ -489,4 +489,9 @@ public class ProfileController {
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature); now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
} }
private static Map<String, Boolean> getAccountCapabilities(final Account account) {
return Arrays.stream(DeviceCapability.values())
.filter(DeviceCapability::includeInProfile)
.collect(Collectors.toMap(Enum::name, account::hasCapability));
}
} }

View File

@ -40,11 +40,11 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest; import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure; import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.RegistrationRequest; import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.DeviceSpec; import org.whispersystems.textsecuregcm.storage.DeviceSpec;
import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@ -127,7 +127,7 @@ public class RegistrationController {
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays()); 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) // 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" // before we'll let them create a new account "from scratch"
throw new WebApplicationException(Response.status(409, "device transfer available").build()); throw new WebApplicationException(Response.status(409, "device transfer available").build());
@ -171,7 +171,7 @@ public class RegistrationController {
account.getPhoneNumberIdentifier(), account.getPhoneNumberIdentifier(),
account.getUsernameHash().orElse(null), account.getUsernameHash().orElse(null),
account.getUsernameLinkHandle(), account.getUsernameLinkHandle(),
existingAccount.map(Account::isStorageSupported).orElse(false)); existingAccount.map(a -> a.hasCapability(DeviceCapability.STORAGE)).orElse(false));
} }
} }

View File

@ -11,12 +11,14 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.validation.constraints.AssertTrue; import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Size; import javax.validation.constraints.Size;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; 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.ByteArrayAdapter;
import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize; import org.whispersystems.textsecuregcm.util.ExactlySize;
public class AccountAttributes { public class AccountAttributes {
@ -47,7 +49,10 @@ public class AccountAttributes {
private boolean unrestrictedUnidentifiedAccess; private boolean unrestrictedUnidentifiedAccess;
@JsonProperty @JsonProperty
private DeviceCapabilities capabilities; @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)
@JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)
@Nullable
private Set<DeviceCapability> capabilities;
@JsonProperty @JsonProperty
private boolean discoverableByPhoneNumber = true; private boolean discoverableByPhoneNumber = true;
@ -68,7 +73,7 @@ public class AccountAttributes {
final byte[] name, final byte[] name,
final String registrationLock, final String registrationLock,
final boolean discoverableByPhoneNumber, final boolean discoverableByPhoneNumber,
final DeviceCapabilities capabilities) { final Set<DeviceCapability> capabilities) {
this.fetchesMessages = fetchesMessages; this.fetchesMessages = fetchesMessages;
this.registrationId = registrationId; this.registrationId = registrationId;
this.phoneNumberIdentityRegistrationId = phoneNumberIdentifierRegistrationId; this.phoneNumberIdentityRegistrationId = phoneNumberIdentifierRegistrationId;
@ -106,7 +111,8 @@ public class AccountAttributes {
return unrestrictedUnidentifiedAccess; return unrestrictedUnidentifiedAccess;
} }
public DeviceCapabilities getCapabilities() { @Nullable
public Set<DeviceCapability> getCapabilities() {
return capabilities; return capabilities;
} }
@ -130,11 +136,6 @@ public class AccountAttributes {
return this; return this;
} }
@VisibleForTesting
public void setPhoneNumberIdentityRegistrationId(final Integer phoneNumberIdentityRegistrationId) {
this.phoneNumberIdentityRegistrationId = phoneNumberIdentityRegistrationId;
}
@AssertTrue @AssertTrue
public boolean isEachRegistrationIdValid() { public boolean isEachRegistrationIdValid() {
return validRegistrationId(registrationId) && validRegistrationId(phoneNumberIdentityRegistrationId); return validRegistrationId(registrationId) && validRegistrationId(phoneNumberIdentityRegistrationId);

View File

@ -15,6 +15,7 @@ import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter; import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
import java.util.List; import java.util.List;
import java.util.Map;
public class BaseProfileResponse { public class BaseProfileResponse {
@ -32,7 +33,7 @@ public class BaseProfileResponse {
private boolean unrestrictedUnidentifiedAccess; private boolean unrestrictedUnidentifiedAccess;
@JsonProperty @JsonProperty
private UserCapabilities capabilities; private Map<String, Boolean> capabilities;
@JsonProperty @JsonProperty
private List<Badge> badges; private List<Badge> badges;
@ -48,7 +49,7 @@ public class BaseProfileResponse {
public BaseProfileResponse(final IdentityKey identityKey, public BaseProfileResponse(final IdentityKey identityKey,
final byte[] unidentifiedAccess, final byte[] unidentifiedAccess,
final boolean unrestrictedUnidentifiedAccess, final boolean unrestrictedUnidentifiedAccess,
final UserCapabilities capabilities, final Map<String, Boolean> capabilities,
final List<Badge> badges, final List<Badge> badges,
final ServiceIdentifier uuid) { final ServiceIdentifier uuid) {
@ -72,7 +73,7 @@ public class BaseProfileResponse {
return unrestrictedUnidentifiedAccess; return unrestrictedUnidentifiedAccess;
} }
public UserCapabilities getCapabilities() { public Map<String, Boolean> getCapabilities() {
return capabilities; return capabilities;
} }

View File

@ -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());
}
}

View File

@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import io.grpc.Status; import io.grpc.Status;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.signal.chat.device.ClearPushTokenRequest; 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.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -200,15 +203,21 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
public Mono<SetCapabilitiesResponse> setCapabilities(final SetCapabilitiesRequest request) { public Mono<SetCapabilitiesResponse> setCapabilities(final SetCapabilitiesRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Set<DeviceCapability> 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())) return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.flatMap(account -> .flatMap(account ->
Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(),
d -> d.setCapabilities(new Device.DeviceCapabilities( d -> d.setCapabilities(capabilities))))
request.getStorage(),
request.getTransfer(),
request.getDeleteSync(),
request.getVersionedExpirationTimer())))))
.thenReturn(SetCapabilitiesResponse.newBuilder().build()); .thenReturn(SetCapabilitiesResponse.newBuilder().build());
} }
} }

View File

@ -9,14 +9,15 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import io.grpc.Status; import io.grpc.Status;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.signal.chat.profile.AccountCapabilities;
import org.signal.chat.profile.Badge; import org.signal.chat.profile.Badge;
import org.signal.chat.profile.BadgeSvg; import org.signal.chat.profile.BadgeSvg;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.UserCapabilities;
import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException; 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.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.ProfileHelper; import org.whispersystems.textsecuregcm.util.ProfileHelper;
@ -80,11 +81,21 @@ public class ProfileGrpcHelper {
} }
@VisibleForTesting @VisibleForTesting
static UserCapabilities buildUserCapabilities(final org.whispersystems.textsecuregcm.entities.UserCapabilities capabilities) { static AccountCapabilities buildAccountCapabilities(final Account account) {
return UserCapabilities.newBuilder() final AccountCapabilities.Builder capabilitiesBuilder = AccountCapabilities.newBuilder();
.setDeleteSync(capabilities.deleteSync())
.setVersionedExpirationTimer(capabilities.versionedExpirationTimer()) Arrays.stream(DeviceCapability.values())
.build(); .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<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) { private static List<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) {
@ -105,7 +116,7 @@ public class ProfileGrpcHelper {
final ProfileBadgeConverter profileBadgeConverter) { final ProfileBadgeConverter profileBadgeConverter) {
final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder() final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder()
.setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize())) .setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize()))
.setCapabilities(buildUserCapabilities(org.whispersystems.textsecuregcm.entities.UserCapabilities.createForAccount(targetAccount))); .setCapabilities(buildAccountCapabilities(targetAccount));
switch (targetIdentifier.identityType()) { switch (targetIdentifier.identityType()) {
case ACI -> { case ACI -> {
@ -113,7 +124,7 @@ public class ProfileGrpcHelper {
.addAllBadges(buildBadges(profileBadgeConverter.convert( .addAllBadges(buildBadges(profileBadgeConverter.convert(
RequestAttributesUtil.getAvailableAcceptedLocales(), RequestAttributesUtil.getAvailableAcceptedLocales(),
targetAccount.getBadges(), targetAccount.getBadges(),
ProfileHelper.isSelfProfileRequest(requesterUuid, (AciServiceIdentifier) targetIdentifier)))); ProfileHelper.isSelfProfileRequest(requesterUuid, targetIdentifier))));
targetAccount.getUnidentifiedAccessKey() targetAccount.getUnidentifiedAccessKey()
.map(UnidentifiedAccessChecksum::generateFor) .map(UnidentifiedAccessChecksum::generateFor)

View File

@ -18,7 +18,6 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Predicate;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.signal.libsignal.protocol.IdentityKey; 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.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter; import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
@ -279,33 +277,14 @@ public class Account {
return devices.stream().filter(device -> device.getId() == deviceId).findFirst(); return devices.stream().filter(device -> device.getId() == deviceId).findFirst();
} }
public boolean isStorageSupported() { public boolean hasCapability(final DeviceCapability capability) {
requireNotStale(); requireNotStale();
return devices.stream().anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().storage()); return switch (capability.getAccountCapabilityMode()) {
} case PRIMARY_DEVICE -> getPrimaryDevice().hasCapability(capability);
case ANY_DEVICE -> devices.stream().anyMatch(device -> device.hasCapability(capability));
public boolean isTransferSupported() { case ALL_DEVICES -> devices.stream().allMatch(device -> device.hasCapability(capability));
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<DeviceCapabilities> predicate) {
requireNotStale();
return devices.stream()
.allMatch(device -> device.getCapabilities() != null && predicate.test(device.getCapabilities()));
} }
public byte getNextDeviceId() { public byte getNextDeviceId() {

View File

@ -9,12 +9,17 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.time.Duration; import java.time.Duration;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import com.google.common.annotations.VisibleForTesting;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
import org.whispersystems.textsecuregcm.util.DeviceNameByteArrayAdapter; import org.whispersystems.textsecuregcm.util.DeviceNameByteArrayAdapter;
public class Device { public class Device {
@ -72,7 +77,9 @@ public class Device {
private String userAgent; private String userAgent;
@JsonProperty @JsonProperty
private DeviceCapabilities capabilities; @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)
@JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)
private Set<DeviceCapability> capabilities = Collections.emptySet();
public String getApnId() { public String getApnId() {
return apnId; return apnId;
@ -166,13 +173,19 @@ public class Device {
return new SaltedTokenHash(authToken, salt); return new SaltedTokenHash(authToken, salt);
} }
@Nullable @VisibleForTesting
public DeviceCapabilities getCapabilities() { public Set<DeviceCapability> getCapabilities() {
return capabilities; return capabilities;
} }
public void setCapabilities(DeviceCapabilities capabilities) { public void setCapabilities(@Nullable final Set<DeviceCapability> capabilities) {
this.capabilities = 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() { public boolean isExpired() {
@ -220,8 +233,4 @@ public class Device {
public String getUserAgent() { public String getUserAgent() {
return this.userAgent; return this.userAgent;
} }
public record DeviceCapabilities(boolean storage, boolean transfer, boolean deleteSync,
boolean versionedExpirationTimer) {
}
} }

View File

@ -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);
}
}

View File

@ -1,21 +1,22 @@
package org.whispersystems.textsecuregcm.storage; 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.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.util.Util; 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( public record DeviceSpec(
byte[] deviceNameCiphertext, byte[] deviceNameCiphertext,
String password, String password,
String signalAgent, String signalAgent,
Device.DeviceCapabilities capabilities, Set<DeviceCapability> capabilities,
int aciRegistrationId, int aciRegistrationId,
int pniRegistrationId, int pniRegistrationId,
boolean fetchesMessages, boolean fetchesMessages,

View File

@ -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<Map<String, Boolean>> STRING_TO_BOOLEAN_MAP_TYPE = new TypeReference<>() {};
private DeviceCapabilityAdapter() {
}
public static class Serializer extends JsonSerializer<Set<DeviceCapability>> {
@Override
public void serialize(final Set<DeviceCapability> 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<Set<DeviceCapability>> {
@Override
public Set<DeviceCapability> deserialize(final JsonParser jsonParser,
final DeserializationContext deserializationContext) throws IOException {
final Map<String, Boolean> capabilitiesMap = jsonParser.readValueAs(STRING_TO_BOOLEAN_MAP_TYPE);
final EnumSet<DeviceCapability> 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;
}
}
}

View File

@ -92,3 +92,11 @@ message KemSignedPreKey {
*/ */
bytes signature = 3; 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;
}

View File

@ -9,6 +9,8 @@ option java_multiple_files = true;
package org.signal.chat.device; package org.signal.chat.device;
import "org/signal/chat/common.proto";
/** /**
* Provides methods for working with devices attached to a Signal account. * Provides methods for working with devices attached to a Signal account.
*/ */
@ -151,10 +153,7 @@ message ClearPushTokenRequest {}
message ClearPushTokenResponse {} message ClearPushTokenResponse {}
message SetCapabilitiesRequest { message SetCapabilitiesRequest {
bool storage = 1; repeated common.DeviceCapability capabilities = 1;
bool transfer = 2;
bool deleteSync = 3;
bool versionedExpirationTimer = 4;
} }
message SetCapabilitiesResponse {} message SetCapabilitiesResponse {}

View File

@ -241,7 +241,7 @@ message GetUnversionedProfileResponse {
/** /**
* A list of capabilities enabled on the account. * A list of capabilities enabled on the account.
*/ */
UserCapabilities capabilities = 4; AccountCapabilities capabilities = 4;
/** /**
* A list of badges associated with the account. * A list of badges associated with the account.
*/ */
@ -317,15 +317,8 @@ message ProfileAvatarUploadAttributes {
bytes signature = 7; bytes signature = 7;
} }
message UserCapabilities { message AccountCapabilities {
/** repeated common.DeviceCapability capabilities = 1;
* 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 Badge { message Badge {

View File

@ -84,6 +84,7 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
@ -185,7 +186,7 @@ class AccountControllerTest {
new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis()))); new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis())));
when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID()); when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID());
when(senderHasStorage.isStorageSupported()).thenReturn(true); when(senderHasStorage.hasCapability(DeviceCapability.STORAGE)).thenReturn(true);
when(senderHasStorage.getRegistrationLock()).thenReturn( when(senderHasStorage.getRegistrationLock()).thenReturn(
new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis()))); new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis())));

View File

@ -30,10 +30,12 @@ import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Base64; import java.util.Base64;
import java.util.EnumSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import java.util.stream.Stream; 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.Arguments;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import org.junitpioneer.jupiter.cartesian.CartesianTest;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.Curve; 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.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest; import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
import org.whispersystems.textsecuregcm.entities.DeviceInfo; 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.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest; import org.whispersystems.textsecuregcm.entities.LinkDeviceRequest;
import org.whispersystems.textsecuregcm.entities.LinkDeviceResponse;
import org.whispersystems.textsecuregcm.entities.RemoteAttachment; import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
import org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;
import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest; import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest;
import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest; import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest;
import org.whispersystems.textsecuregcm.identity.IdentityType; 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.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager; import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
import org.whispersystems.textsecuregcm.storage.Device; 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.DeviceSpec;
import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException; import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper;
import org.whispersystems.textsecuregcm.tests.util.MockRedisFuture; import org.whispersystems.textsecuregcm.tests.util.MockRedisFuture;
import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
@ExtendWith(DropwizardExtensionsSupport.class) @ExtendWith(DropwizardExtensionsSupport.class)
class DeviceControllerTest { class DeviceControllerTest {
@ -216,7 +219,7 @@ class DeviceControllerTest {
when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
final AccountAttributes accountAttributes = new AccountAttributes(fetchesMessages, 1234, 5678, 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", final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
accountAttributes, accountAttributes,
@ -256,64 +259,11 @@ class DeviceControllerTest {
); );
} }
@ParameterizedTest @CartesianTest
@MethodSource void deviceDowngrade(@CartesianTest.Enum final DeviceCapability capability,
void deviceDowngradeDeleteSync(final boolean accountSupportsDeleteSync, final boolean deviceSupportsDeleteSync, final int expectedStatus) { @CartesianTest.Values(booleans = {true, false}) final boolean accountHasCapability,
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); @CartesianTest.Values(booleans = {true, false}) final boolean requestHasCapability) {
when(accountsManager.addDevice(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class))));
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<Arguments> 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.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));
when(accountsManager.addDevice(any(), any(), any())) when(accountsManager.addDevice(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class)))); .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.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.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(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
final Set<DeviceCapability> requestCapabilities = EnumSet.allOf(DeviceCapability.class);
if (!requestHasCapability) {
requestCapabilities.remove(capability);
}
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", 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")))); 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() try (final Response response = resources.getJerseyTest()
.target("/v1/devices/link") .target("/v1/devices/link")
.request() .request()
@ -357,14 +316,6 @@ class DeviceControllerTest {
} }
} }
private static List<Arguments> 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 @Test
void linkDeviceAtomicBadCredentials() { void linkDeviceAtomicBadCredentials() {
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); 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)); when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
final AccountAttributes accountAttributes = new AccountAttributes(true, 1234, 5678, 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", final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
accountAttributes, accountAttributes,
@ -769,7 +720,7 @@ class DeviceControllerTest {
when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", 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())); new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.of(new ApnRegistrationId("apn")), Optional.empty()));
try (final Response response = resources.getJerseyTest() try (final Response response = resources.getJerseyTest()
@ -828,14 +779,13 @@ class DeviceControllerTest {
@Test @Test
void putCapabilitiesSuccessTest() { void putCapabilitiesSuccessTest() {
final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, false, false);
try (final Response response = resources try (final Response response = resources
.getJerseyTest() .getJerseyTest()
.target("/v1/devices/capabilities") .target("/v1/devices/capabilities")
.request() .request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") .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.getStatus()).isEqualTo(204);
assertThat(response.hasEntity()).isFalse(); assertThat(response.hasEntity()).isFalse();

View File

@ -100,6 +100,7 @@ import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
@ -443,16 +444,16 @@ class ProfileControllerTest {
void testProfileCapabilities( void testProfileCapabilities(
@CartesianTest.Values(booleans = {true, false}) final boolean isDeleteSyncSupported, @CartesianTest.Values(booleans = {true, false}) final boolean isDeleteSyncSupported,
@CartesianTest.Values(booleans = {true, false}) final boolean isVersionedExpirationTimerSupported) { @CartesianTest.Values(booleans = {true, false}) final boolean isVersionedExpirationTimerSupported) {
when(capabilitiesAccount.isDeleteSyncSupported()).thenReturn(isDeleteSyncSupported); when(capabilitiesAccount.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(isDeleteSyncSupported);
when(capabilitiesAccount.isVersionedExpirationTimerSupported()).thenReturn(isVersionedExpirationTimerSupported); when(capabilitiesAccount.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(isVersionedExpirationTimerSupported);
final BaseProfileResponse profile = resources.getJerseyTest() final BaseProfileResponse profile = resources.getJerseyTest()
.target("/v1/profile/" + AuthHelper.VALID_UUID) .target("/v1/profile/" + AuthHelper.VALID_UUID)
.request() .request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(BaseProfileResponse.class); .get(BaseProfileResponse.class);
assertEquals(isDeleteSyncSupported, profile.getCapabilities().deleteSync()); assertEquals(isDeleteSyncSupported, profile.getCapabilities().get(DeviceCapability.DELETE_SYNC.name()));
assertEquals(isVersionedExpirationTimerSupported, profile.getCapabilities().versionedExpirationTimer()); assertEquals(isVersionedExpirationTimerSupported, profile.getCapabilities().get(DeviceCapability.VERSIONED_EXPIRATION_TIMER.name()));
} }
@Test @Test

View File

@ -74,8 +74,9 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker; import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceSpec;
import org.whispersystems.textsecuregcm.storage.Device; 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.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper;
@ -309,7 +310,7 @@ class RegistrationControllerTest {
} }
@Test @Test
void recoveryPasswordManagerVerificationFalse() throws InterruptedException { void recoveryPasswordManagerVerificationFalse() {
when(registrationRecoveryPasswordsManager.verify(any(), any())) when(registrationRecoveryPasswordsManager.verify(any(), any()))
.thenReturn(CompletableFuture.completedFuture(false)); .thenReturn(CompletableFuture.completedFuture(false));
@ -380,7 +381,7 @@ class RegistrationControllerTest {
final Account account = mock(Account.class); final Account account = mock(Account.class);
when(accountsManager.getByE164(any())).thenReturn(Optional.of(account)); when(accountsManager.getByE164(any())).thenReturn(Optional.of(account));
when(account.isTransferSupported()).thenReturn(deviceTransferSupported); when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(deviceTransferSupported);
final int expectedStatus; final int expectedStatus;
if (deviceTransferSupported) { if (deviceTransferSupported) {
@ -441,7 +442,7 @@ class RegistrationControllerTest {
final Optional<Account> maybeAccount; final Optional<Account> maybeAccount;
if (existingAccount) { if (existingAccount) {
final Account account = mock(Account.class); final Account account = mock(Account.class);
when(account.isTransferSupported()).thenReturn(transferSupported); when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(transferSupported);
maybeAccount = Optional.of(account); maybeAccount = Optional.of(account);
} else { } else {
maybeAccount = Optional.empty(); maybeAccount = Optional.empty();
@ -526,10 +527,10 @@ class RegistrationControllerTest {
} }
final AccountAttributes fetchesMessagesAccountAttributes = 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 = 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( return Stream.of(
// "Fetches messages" is true, but an APNs token is provided // "Fetches messages" is true, but an APNs token is provided
@ -615,7 +616,7 @@ class RegistrationControllerTest {
} }
final AccountAttributes accountAttributes = 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( return Stream.of(
// Signed PNI EC pre-key is missing // Signed PNI EC pre-key is missing
@ -786,13 +787,13 @@ class RegistrationControllerTest {
final int registrationId = 1; final int registrationId = 1;
final int pniRegistrationId = 2; final int pniRegistrationId = 2;
final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(false, false, false, false); final Set<DeviceCapability> deviceCapabilities = Set.of();
final AccountAttributes fetchesMessagesAccountAttributes = 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 = 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 apnsToken = "apns-token";
final String gcmToken = "gcm-token"; final String gcmToken = "gcm-token";
@ -906,7 +907,7 @@ class RegistrationControllerTest {
final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey()); final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());
final AccountAttributes accountAttributes = new AccountAttributes(true, registrationId, pniRegistrationId, "name".getBytes(StandardCharsets.UTF_8), "reglock", 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( final RegistrationRequest request = new RegistrationRequest(
Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)), Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)),

View File

@ -20,8 +20,10 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Stream; 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.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil;
class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, DevicesGrpc.DevicesBlockingStub> { class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, DevicesGrpc.DevicesBlockingStub> {
@ -439,18 +442,43 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, Devi
final Device device = mock(Device.class); final Device device = mock(Device.class);
when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device)); when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));
final SetCapabilitiesResponse ignored = authenticatedServiceStub().setCapabilities(SetCapabilitiesRequest.newBuilder() final SetCapabilitiesRequest.Builder requestBuilder = SetCapabilitiesRequest.newBuilder();
.setStorage(storage)
.setTransfer(transfer)
.setDeleteSync(deleteSync)
.setVersionedExpirationTimer(versionedExpirationTimer)
.build());
final Device.DeviceCapabilities expectedCapabilities = new Device.DeviceCapabilities( if (storage) {
storage, requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_STORAGE);
transfer, }
deleteSync,
versionedExpirationTimer); if (transfer) {
requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_TRANSFER);
}
if (deleteSync) {
requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_DELETE_SYNC);
}
if (versionedExpirationTimer) {
requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_VERSIONED_EXPIRATION_TIMER);
}
final SetCapabilitiesResponse ignored = authenticatedServiceStub().setCapabilities(requestBuilder.build());
final Set<DeviceCapability> 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); verify(device).setCapabilities(expectedCapabilities);
} }

View File

@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.grpc; package org.whispersystems.textsecuregcm.grpc;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; 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.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyBoolean;
@ -37,6 +36,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock; import org.mockito.Mock;
import org.signal.chat.common.IdentityType; import org.signal.chat.common.IdentityType;
import org.signal.chat.common.ServiceIdentifier; import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.AccountCapabilities;
import org.signal.chat.profile.CredentialType; import org.signal.chat.profile.CredentialType;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest; import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; 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.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerPublicParams;
import org.signal.libsignal.zkgroup.ServerSecretParams; import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; 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.ProfileKeyCommitment;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext; 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.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@ -143,6 +140,8 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false); when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey); when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey);
when(account.hasCapability(any())).thenReturn(false);
when(account.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(true);
when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier)).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier)).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder() final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()
@ -162,7 +161,9 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
.setIdentityKey(ByteString.copyFrom(identityKey.serialize())) .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum)) .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
.setUnrestrictedUnidentifiedAccess(false) .setUnrestrictedUnidentifiedAccess(false)
.setCapabilities(ProfileGrpcHelper.buildUserCapabilities(UserCapabilities.createForAccount(account))) .setCapabilities(AccountCapabilities.newBuilder()
.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_DELETE_SYNC)
.build())
.addAllBadges(ProfileGrpcHelper.buildBadges(badges)) .addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.build(); .build();
@ -214,7 +215,7 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileA
final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder() final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder()
.setIdentityKey(ByteString.copyFrom(identityKey.serialize())) .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.setUnrestrictedUnidentifiedAccess(false) .setUnrestrictedUnidentifiedAccess(false)
.setCapabilities(ProfileGrpcHelper.buildUserCapabilities(UserCapabilities.createForAccount(account))) .setCapabilities(ProfileGrpcHelper.buildAccountCapabilities(account))
.addAllBadges(ProfileGrpcHelper.buildBadges(badges)) .addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.build(); .build();

View File

@ -50,6 +50,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.signal.chat.common.IdentityType; import org.signal.chat.common.IdentityType;
import org.signal.chat.common.ServiceIdentifier; import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.AccountCapabilities;
import org.signal.chat.profile.CredentialType; import org.signal.chat.profile.CredentialType;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;
@ -86,7 +87,6 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsCon
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiter;
@ -95,6 +95,7 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
@ -426,6 +427,8 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(true); when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(true);
when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(account.getBadges()).thenReturn(Collections.emptyList()); when(account.getBadges()).thenReturn(Collections.emptyList());
when(account.hasCapability(any())).thenReturn(false);
when(account.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(true);
when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges); when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
@ -436,7 +439,9 @@ public class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcServic
.setIdentityKey(ByteString.copyFrom(identityKey.serialize())) .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum)) .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
.setUnrestrictedUnidentifiedAccess(true) .setUnrestrictedUnidentifiedAccess(true)
.setCapabilities(ProfileGrpcHelper.buildUserCapabilities(UserCapabilities.createForAccount(account))) .setCapabilities(AccountCapabilities.newBuilder()
.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_DELETE_SYNC)
.build())
.addAllBadges(ProfileGrpcHelper.buildBadges(badges)) .addAllBadges(ProfileGrpcHelper.buildBadges(badges))
.build(); .build();

View File

@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -188,18 +189,15 @@ public class AccountCreationDeletionIntegrationTest {
final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8); final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8);
final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16); final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16);
final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities( final Set<DeviceCapability> deviceCapabilities = Set.of();
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean());
final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(), final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(),
registrationId, registrationId,
pniRegistrationId, pniRegistrationId,
deviceName, deviceName,
registrationLockSecret, registrationLockSecret,
discoverableByPhoneNumber, deviceCapabilities); discoverableByPhoneNumber,
deviceCapabilities);
final List<AccountBadge> badges = new ArrayList<>(List.of(new AccountBadge( final List<AccountBadge> badges = new ArrayList<>(List.of(new AccountBadge(
RandomStringUtils.randomAlphabetic(8), RandomStringUtils.randomAlphabetic(8),
@ -303,15 +301,14 @@ public class AccountCreationDeletionIntegrationTest {
final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair); final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);
final Account originalAccount = accountsManager.create(number, final Account originalAccount = accountsManager.create(number,
new AccountAttributes(true, 1, 1, "name".getBytes(StandardCharsets.UTF_8), "registration-lock", false, new AccountAttributes(true, 1, 1, "name".getBytes(StandardCharsets.UTF_8), "registration-lock", false, Set.of()),
new Device.DeviceCapabilities(false, false, false, false)),
Collections.emptyList(), Collections.emptyList(),
new IdentityKey(aciKeyPair.getPublicKey()), new IdentityKey(aciKeyPair.getPublicKey()),
new IdentityKey(pniKeyPair.getPublicKey()), new IdentityKey(pniKeyPair.getPublicKey()),
new DeviceSpec(null, new DeviceSpec(null,
"password?", "password?",
"OWI", "OWI",
new Device.DeviceCapabilities(false, false, false, false), Set.of(),
1, 1,
2, 2,
true, true,
@ -333,11 +330,7 @@ public class AccountCreationDeletionIntegrationTest {
final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8); final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8);
final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16); final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16);
final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities( final Set<DeviceCapability> deviceCapabilities = Set.of();
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean());
final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(), final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(),
registrationId, registrationId,
@ -424,11 +417,7 @@ public class AccountCreationDeletionIntegrationTest {
final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8); final byte[] deviceName = RandomStringUtils.randomAlphabetic(16).getBytes(StandardCharsets.UTF_8);
final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16); final String registrationLockSecret = RandomStringUtils.randomAlphanumeric(16);
final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities( final Set<DeviceCapability> deviceCapabilities = Set.of();
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean(),
ThreadLocalRandom.current().nextBoolean());
final AccountAttributes accountAttributes = new AccountAttributes(true, final AccountAttributes accountAttributes = new AccountAttributes(true,
registrationId, registrationId,
@ -498,7 +487,7 @@ public class AccountCreationDeletionIntegrationTest {
final int pniRegistrationId, final int pniRegistrationId,
final byte[] deviceName, final byte[] deviceName,
final boolean discoverableByPhoneNumber, final boolean discoverableByPhoneNumber,
final Device.DeviceCapabilities deviceCapabilities, final Set<DeviceCapability> deviceCapabilities,
final List<AccountBadge> badges, final List<AccountBadge> badges,
final Optional<ApnRegistrationId> maybeApnRegistrationId, final Optional<ApnRegistrationId> maybeApnRegistrationId,
final Optional<GcmRegistrationId> maybeGcmRegistrationId, final Optional<GcmRegistrationId> maybeGcmRegistrationId,

View File

@ -29,7 +29,6 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestClock;
@ -64,21 +63,16 @@ class AccountTest {
when(oldSecondaryDevice.getId()).thenReturn(deviceId2); when(oldSecondaryDevice.getId()).thenReturn(deviceId2);
when(deleteSyncCapableDevice.getId()).thenReturn((byte) 1); when(deleteSyncCapableDevice.getId()).thenReturn((byte) 1);
when(deleteSyncCapableDevice.getCapabilities()) when(deleteSyncCapableDevice.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(true);
.thenReturn(new DeviceCapabilities(true, true, true, false));
when(deleteSyncIncapableDevice.getId()).thenReturn((byte) 2); when(deleteSyncIncapableDevice.getId()).thenReturn((byte) 2);
when(deleteSyncIncapableDevice.getCapabilities()) when(deleteSyncIncapableDevice.hasCapability(DeviceCapability.DELETE_SYNC)).thenReturn(false);
.thenReturn(new DeviceCapabilities(true, true, false, false));
when(versionedExpirationTimerCapableDevice.getId()).thenReturn((byte) 1); when(versionedExpirationTimerCapableDevice.getId()).thenReturn((byte) 1);
when(versionedExpirationTimerCapableDevice.getCapabilities()) when(versionedExpirationTimerCapableDevice.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(true);
.thenReturn(new DeviceCapabilities(true, true, false, true));
when(versionedExpirationTimerIncapableDevice.getId()).thenReturn((byte) 2); when(versionedExpirationTimerIncapableDevice.getId()).thenReturn((byte) 2);
when(versionedExpirationTimerIncapableDevice.getCapabilities()) when(versionedExpirationTimerIncapableDevice.hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER)).thenReturn(false);
.thenReturn(new DeviceCapabilities(true, true, false, false));
} }
@Test @Test
@ -87,42 +81,36 @@ class AccountTest {
final Device nonTransferCapablePrimaryDevice = mock(Device.class); final Device nonTransferCapablePrimaryDevice = mock(Device.class);
final Device transferCapableLinkedDevice = 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.getId()).thenReturn(Device.PRIMARY_ID);
when(transferCapablePrimaryDevice.isPrimary()).thenReturn(true); 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.getId()).thenReturn(Device.PRIMARY_ID);
when(nonTransferCapablePrimaryDevice.isPrimary()).thenReturn(true); 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.getId()).thenReturn((byte) 2);
when(transferCapableLinkedDevice.isPrimary()).thenReturn(false); when(transferCapableLinkedDevice.isPrimary()).thenReturn(false);
when(transferCapableLinkedDevice.getCapabilities()).thenReturn(transferCapabilities); when(transferCapableLinkedDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(true);
when(transferCapabilities.transfer()).thenReturn(true);
when(nonTransferCapabilities.transfer()).thenReturn(false);
{ {
final Account transferablePrimaryAccount = final Account transferablePrimaryAccount =
AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(transferCapablePrimaryDevice), "1234".getBytes()); AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(transferCapablePrimaryDevice), "1234".getBytes());
assertTrue(transferablePrimaryAccount.isTransferSupported()); assertTrue(transferablePrimaryAccount.hasCapability(DeviceCapability.TRANSFER));
} }
{ {
final Account nonTransferablePrimaryAccount = final Account nonTransferablePrimaryAccount =
AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapablePrimaryDevice), "1234".getBytes()); 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()); 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() { void isDeleteSyncSupported() {
assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
List.of(deleteSyncCapableDevice), 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(), assertFalse(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
List.of(deleteSyncIncapableDevice, deleteSyncCapableDevice), List.of(deleteSyncIncapableDevice, deleteSyncCapableDevice),
"1234".getBytes(StandardCharsets.UTF_8)).isDeleteSyncSupported()); "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.DELETE_SYNC));
} }
@Test @Test
void isVersionedExpirationTimerSupported() { void isVersionedExpirationTimerSupported() {
assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), assertTrue(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
List.of(versionedExpirationTimerCapableDevice), 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(), assertFalse(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
List.of(versionedExpirationTimerIncapableDevice, versionedExpirationTimerCapableDevice), List.of(versionedExpirationTimerIncapableDevice, versionedExpirationTimerCapableDevice),
"1234".getBytes(StandardCharsets.UTF_8)).isVersionedExpirationTimerSupported()); "1234".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.VERSIONED_EXPIRATION_TIMER));
} }
@Test @Test
@ -248,7 +236,7 @@ class AccountTest {
} }
@Test @Test
public void testAccountClassJsonFilterIdMatchesClassName() throws Exception { public void testAccountClassJsonFilterIdMatchesClassName() {
// Some logic relies on the @JsonFilter name being equal to the class name. // 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. // This test is just making sure that annotation is there and that the ID matches class name.
final Optional<Annotation> maybeJsonFilterAnnotation = Arrays.stream(Account.class.getAnnotations()) final Optional<Annotation> maybeJsonFilterAnnotation = Arrays.stream(Account.class.getAnnotations())

View File

@ -18,6 +18,7 @@ import java.time.Clock;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -198,7 +199,7 @@ class AccountsManagerChangeNumberIntegrationTest {
final int rotatedPniRegistrationId = 17; final int rotatedPniRegistrationId = 17;
final ECKeyPair rotatedPniIdentityKeyPair = Curve.generateKeyPair(); final ECKeyPair rotatedPniIdentityKeyPair = Curve.generateKeyPair();
final ECSignedPreKey rotatedSignedPreKey = KeysHelper.signedECPreKey(1L, rotatedPniIdentityKeyPair); 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); final Account account = AccountsHelper.createAccount(accountsManager, originalNumber, accountAttributes);
keysManager.storeEcSignedPreKeys(account.getIdentifier(IdentityType.ACI), keysManager.storeEcSignedPreKeys(account.getIdentifier(IdentityType.ACI),

View File

@ -25,6 +25,7 @@ import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -161,7 +162,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
null, null,
"password", "password",
null, null,
new Device.DeviceCapabilities(false, false, false, false), Set.of(),
1, 1,
2, 2,
true, true,

View File

@ -49,6 +49,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
@ -57,6 +58,7 @@ import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout; 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.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager; import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecovery2Client; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecovery2Client;
import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryException; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryException;
import org.whispersystems.textsecuregcm.storage.AccountsManager.UsernameReservation; 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.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
import org.whispersystems.textsecuregcm.tests.util.KeysHelper; 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.Pair;
import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import javax.crypto.spec.SecretKeySpec;
@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) @Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
class AccountsManagerTest { class AccountsManagerTest {
@ -930,11 +930,11 @@ class AccountsManagerTest {
@ValueSource(booleans = {true, false}) @ValueSource(booleans = {true, false})
void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException { void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException {
final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, 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); final Account account = createAccount("+18005550123", attributes);
assertEquals(hasStorage, account.isStorageSupported()); assertEquals(hasStorage, account.hasCapability(DeviceCapability.STORAGE));
} }
@Test @Test
@ -955,7 +955,7 @@ class AccountsManagerTest {
final byte[] deviceNameCiphertext = "device-name".getBytes(StandardCharsets.UTF_8); final byte[] deviceNameCiphertext = "device-name".getBytes(StandardCharsets.UTF_8);
final String password = "password"; final String password = "password";
final String signalAgent = "OWT"; final String signalAgent = "OWT";
final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, false, false); final Set<DeviceCapability> deviceCapabilities = Set.of();
final int aciRegistrationId = 17; final int aciRegistrationId = 17;
final int pniRegistrationId = 19; final int pniRegistrationId = 19;
final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair); final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);
@ -1005,7 +1005,7 @@ class AccountsManagerTest {
assertEquals(deviceNameCiphertext, device.getName()); assertEquals(deviceNameCiphertext, device.getName());
assertTrue(device.getAuthTokenHash().verify(password)); assertTrue(device.getAuthTokenHash().verify(password));
assertEquals(signalAgent, device.getUserAgent()); assertEquals(signalAgent, device.getUserAgent());
assertEquals(deviceCapabilities, device.getCapabilities()); assertEquals(Collections.emptySet(), device.getCapabilities());
assertEquals(aciRegistrationId, device.getRegistrationId()); assertEquals(aciRegistrationId, device.getRegistrationId());
assertEquals(pniRegistrationId, device.getPhoneNumberIdentityRegistrationId().getAsInt()); assertEquals(pniRegistrationId, device.getPhoneNumberIdentityRegistrationId().getAsInt());
assertTrue(device.getFetchesMessages()); assertTrue(device.getFetchesMessages());

View File

@ -17,6 +17,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
@ -190,19 +191,19 @@ public class AddRemoveDeviceIntegrationTest {
final Pair<Account, Device> updatedAccountAndDevice = final Pair<Account, Device> updatedAccountAndDevice =
accountsManager.addDevice(account, new DeviceSpec( accountsManager.addDevice(account, new DeviceSpec(
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, false, false), Set.of(),
1, 1,
2, 2,
true, true,
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(1, aciKeyPair),
KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair),
KeysHelper.signedKEMPreKey(3, aciKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair),
KeysHelper.signedKEMPreKey(4, pniKeyPair)), KeysHelper.signedKEMPreKey(4, pniKeyPair)),
accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)))
.join(); .join();
@ -239,7 +240,7 @@ public class AddRemoveDeviceIntegrationTest {
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, false, false), Set.of(),
1, 1,
2, 2,
true, true,
@ -258,21 +259,21 @@ public class AddRemoveDeviceIntegrationTest {
final CompletionException completionException = assertThrows(CompletionException.class, final CompletionException completionException = assertThrows(CompletionException.class,
() -> accountsManager.addDevice(account, new DeviceSpec( () -> accountsManager.addDevice(account, new DeviceSpec(
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, false, false), Set.of(),
1, 1,
2, 2,
true, true,
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(1, aciKeyPair),
KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair),
KeysHelper.signedKEMPreKey(3, aciKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair),
KeysHelper.signedKEMPreKey(4, pniKeyPair)), KeysHelper.signedKEMPreKey(4, pniKeyPair)),
linkDeviceToken) linkDeviceToken)
.join()); .join());
assertInstanceOf(LinkDeviceTokenAlreadyUsedException.class, completionException.getCause()); assertInstanceOf(LinkDeviceTokenAlreadyUsedException.class, completionException.getCause());
@ -295,19 +296,19 @@ public class AddRemoveDeviceIntegrationTest {
final Pair<Account, Device> updatedAccountAndDevice = final Pair<Account, Device> updatedAccountAndDevice =
accountsManager.addDevice(account, new DeviceSpec( accountsManager.addDevice(account, new DeviceSpec(
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, false, false), Set.of(),
1, 1,
2, 2,
true, true,
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(1, aciKeyPair),
KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair),
KeysHelper.signedKEMPreKey(3, aciKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair),
KeysHelper.signedKEMPreKey(4, pniKeyPair)), KeysHelper.signedKEMPreKey(4, pniKeyPair)),
accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)))
.join(); .join();
@ -349,19 +350,19 @@ public class AddRemoveDeviceIntegrationTest {
final Pair<Account, Device> updatedAccountAndDevice = final Pair<Account, Device> updatedAccountAndDevice =
accountsManager.addDevice(account, new DeviceSpec( accountsManager.addDevice(account, new DeviceSpec(
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, false, false), Set.of(),
1, 1,
2, 2,
true, true,
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(1, aciKeyPair),
KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair),
KeysHelper.signedKEMPreKey(3, aciKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair),
KeysHelper.signedKEMPreKey(4, pniKeyPair)), KeysHelper.signedKEMPreKey(4, pniKeyPair)),
accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI))) accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)))
.join(); .join();
@ -420,7 +421,7 @@ public class AddRemoveDeviceIntegrationTest {
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, true, false), Set.of(),
1, 1,
2, 2,
true, true,
@ -461,7 +462,7 @@ public class AddRemoveDeviceIntegrationTest {
"device-name".getBytes(StandardCharsets.UTF_8), "device-name".getBytes(StandardCharsets.UTF_8),
"password", "password",
"OWT", "OWT",
new Device.DeviceCapabilities(true, true, true, false), Set.of(),
1, 1,
2, 2,
true, true,

View File

@ -147,8 +147,7 @@ public class AccountsHelper {
case "getPrimaryDevice" -> when(updatedAccount.getPrimaryDevice()).thenAnswer(stubbing); case "getPrimaryDevice" -> when(updatedAccount.getPrimaryDevice()).thenAnswer(stubbing);
case "isDiscoverableByPhoneNumber" -> when(updatedAccount.isDiscoverableByPhoneNumber()).thenAnswer(stubbing); case "isDiscoverableByPhoneNumber" -> when(updatedAccount.isDiscoverableByPhoneNumber()).thenAnswer(stubbing);
case "getNextDeviceId" -> when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing); case "getNextDeviceId" -> when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing);
case "isDeleteSyncSupported" -> when(updatedAccount.isDeleteSyncSupported()).thenAnswer(stubbing); case "hasCapability" -> when(updatedAccount.hasCapability(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);
case "isVersionedExpirationTimerSupported" -> when(updatedAccount.isVersionedExpirationTimerSupported()).thenAnswer(stubbing);
case "getRegistrationLock" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing); case "getRegistrationLock" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing);
case "getIdentityKey" -> case "getIdentityKey" ->
when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing); when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);

View File

@ -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<DeviceCapability> 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));
}
}
}