Support linking devices to accounts without phone numbers

This commit is contained in:
Jonathan Klabunde Tomer
2026-08-12 12:18:27 -04:00
committed by Jon Chambers
parent 2c18e4aa1f
commit 48fd25f01e
14 changed files with 241 additions and 134 deletions
@@ -48,6 +48,7 @@ import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.commons.lang3.ObjectUtils;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;
@@ -224,7 +225,7 @@ public class DeviceController {
""")
@ApiResponse(responseCode = "200", description = "The new device was linked to the calling account", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "The given account was not found or the given verification code was incorrect")
@ApiResponse(responseCode = "409", description = "The new device is missing a capability supported by all other devices on the account")
@ApiResponse(responseCode = "409", description = "The new device is missing a capability required for the account")
@ApiResponse(responseCode = "411", description = "The given account already has its maximum number of linked devices")
@ApiResponse(responseCode = "422", description = "The request did not pass validation")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
@@ -234,7 +235,6 @@ public class DeviceController {
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent,
@NotNull @Valid LinkDeviceRequest linkDeviceRequest)
throws RateLimitExceededException, DeviceLimitExceededException {
final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())
.flatMap(accounts::getByAccountIdentifier)
.orElseThrow(ForbiddenException::new);
@@ -242,23 +242,33 @@ public class DeviceController {
final DeviceActivationRequest deviceActivationRequest = linkDeviceRequest.deviceActivationRequest();
final DeviceAttributes deviceAttributes = linkDeviceRequest.deviceAttributes();
if (deviceAttributes.phoneNumberIdentityRegistrationId() == null ||
deviceActivationRequest.pniSignedPreKey().isEmpty() ||
deviceActivationRequest.pniPqLastResortPreKey().isEmpty()) {
throw new WebApplicationException("PNI-associated info must all be provided", 422);
}
rateLimiters.getVerifyDeviceLimiter().validate(account.getAccountIdentifier());
// Check the optional-phone-number capability before checking PNI keys, so we can give a better error code (since an
// older device will improperly supply PNI keys for a PNI-less account)
if (account.getPhoneNumberIdentifierOptional().isEmpty() &&
!linkDeviceRequest.deviceAttributes().capabilities().contains(DeviceCapability.OPTIONAL_PHONE_NUMBER)) {
throw new WebApplicationException("Missing required device capability", 409);
}
final boolean allKeysValid =
PreKeySignatureValidator.validatePreKeySignatures(account.getIdentityKey(IdentityType.ACI),
List.of(deviceActivationRequest.aciSignedPreKey(), deviceActivationRequest.aciPqLastResortPreKey()),
userAgent,
"link-device")
&& PreKeySignatureValidator.validatePreKeySignatures(account.getIdentityKey(IdentityType.PNI),
List.of(deviceActivationRequest.pniSignedPreKey().get(), deviceActivationRequest.pniPqLastResortPreKey().get()),
userAgent,
"link-device");
PreKeySignatureValidator
.validatePreKeySignatures(account.getAccountIdentityKey(),
List.of(deviceActivationRequest.aciSignedPreKey(), deviceActivationRequest.aciPqLastResortPreKey()),
userAgent,
"link-device")
&& account.getPhoneNumberIdentityKey()
.map(pniIdentityKey ->
deviceActivationRequest.pniSignedPreKey().isPresent()
&& deviceActivationRequest.pniPqLastResortPreKey().isPresent()
&& PreKeySignatureValidator.validatePreKeySignatures(
pniIdentityKey,
List.of(deviceActivationRequest.pniSignedPreKey().get(), deviceActivationRequest.pniPqLastResortPreKey().get()),
userAgent,
"link-device"))
.orElse(
deviceActivationRequest.pniSignedPreKey().isEmpty()
&& deviceActivationRequest.pniPqLastResortPreKey().isEmpty());
if (!allKeysValid) {
throw new WebApplicationException(Response.status(422).build());
@@ -287,7 +297,7 @@ public class DeviceController {
}
try {
final Pair<Account, Device> accountAndDevice = accounts.addDevice(account.getIdentifier(IdentityType.ACI),
final Pair<Account, Device> accountAndDevice = accounts.addDevice(account.getAccountIdentifier(),
new DeviceSpec(deviceAttributes.name(),
authorizationHeader.getPassword(),
signalAgent,
@@ -296,18 +306,19 @@ public class DeviceController {
deviceAttributes.registrationId(),
deviceActivationRequest.aciSignedPreKey(),
deviceActivationRequest.aciPqLastResortPreKey()),
Optional.of(new DeviceIdentityInfo(
deviceAttributes.phoneNumberIdentityRegistrationId(),
deviceActivationRequest.pniSignedPreKey().get(),
deviceActivationRequest.pniPqLastResortPreKey().get())),
account.getPhoneNumberIdentityKey().map(_ ->
new DeviceIdentityInfo(
deviceAttributes.phoneNumberIdentityRegistrationId(),
deviceActivationRequest.pniSignedPreKey().get(),
deviceActivationRequest.pniPqLastResortPreKey().get())),
deviceAttributes.fetchesMessages(),
deviceActivationRequest.apnToken(),
deviceActivationRequest.gcmToken()),
linkDeviceRequest.verificationCode());
return new LinkDeviceResponse(
accountAndDevice.first().getIdentifier(IdentityType.ACI),
accountAndDevice.first().getIdentifier(IdentityType.PNI),
accountAndDevice.first().getAccountIdentifier(),
accountAndDevice.first().getPhoneNumberIdentifierOptional(),
accountAndDevice.second().getId());
} catch (final LinkDeviceTokenAlreadyUsedException e) {
throw new ForbiddenException();
@@ -1,10 +1,8 @@
package org.whispersystems.textsecuregcm.entities;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;
public record DeviceActivationRequest(
@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
@@ -29,13 +30,11 @@ public record DeviceAttributes(
Integer phoneNumberIdentityRegistrationId,
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@Size(max = 225)
byte[] name,
@JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)
@JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)
@Nullable
Set<DeviceCapability> capabilities) {
@@ -5,7 +5,8 @@
package org.whispersystems.textsecuregcm.entities;
import java.util.Optional;
import java.util.UUID;
public record LinkDeviceResponse(UUID uuid, UUID pni, byte deviceId) {
public record LinkDeviceResponse(UUID uuid, Optional<UUID> pni, byte deviceId) {
}
@@ -22,7 +22,6 @@ public abstract class PreKeySignatureValidator {
final Collection<SignedPreKey<?>> signedPreKeys,
@Nullable final String userAgent,
final String context) {
final boolean success = signedPreKeys.stream().allMatch(signedPreKey -> signedPreKey.signatureValid(identityKey));
if (!success) {
@@ -20,6 +20,7 @@ public class DeviceCapabilityUtil {
case DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET -> DeviceCapability.SPARSE_POST_QUANTUM_RATCHET;
case DEVICE_CAPABILITY_PROFILES_V2 -> DeviceCapability.PROFILES_V2;
case DEVICE_CAPABILITY_USERNAME_CHANGE_SYNC_MESSAGE -> DeviceCapability.USERNAME_CHANGE_SYNC_MESSAGE;
case DEVICE_CAPABILITY_OPTIONAL_PHONE_NUMBER -> DeviceCapability.OPTIONAL_PHONE_NUMBER;
case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED ->
throw GrpcExceptions.invalidArguments("unrecognized device capability");
};
@@ -33,6 +34,7 @@ public class DeviceCapabilityUtil {
case SPARSE_POST_QUANTUM_RATCHET -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET;
case PROFILES_V2 -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_PROFILES_V2;
case USERNAME_CHANGE_SYNC_MESSAGE -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_USERNAME_CHANGE_SYNC_MESSAGE;
case OPTIONAL_PHONE_NUMBER -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_OPTIONAL_PHONE_NUMBER;
};
}
}
@@ -516,11 +516,10 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
public Pair<Account, Device> addDevice(final UUID accountIdentifier, final DeviceSpec deviceSpec, final String linkDeviceToken)
throws LinkDeviceTokenAlreadyUsedException {
final UUID phoneNumberIdentifier = accounts.getByAccountIdentifier(accountIdentifier)
.map(account -> account.getIdentifier(IdentityType.PNI))
final Account account = accounts.getByAccountIdentifier(accountIdentifier)
.orElseThrow(() -> new IllegalArgumentException("Account not found: " + accountIdentifier));
return accountLockManager.withLock(Set.of(phoneNumberIdentifier),
return accountLockManager.withSingleAccountLock(account,
() -> addDevice(accountIdentifier, deviceSpec, linkDeviceToken, MAX_UPDATE_ATTEMPTS),
accountLockExecutor);
}
@@ -532,16 +531,19 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
final byte nextDeviceId = account.getNextDeviceId();
CompletableFuture.allOf(
CompletableFuture
.allOf(
keysManager.deleteSingleUsePreKeys(account.getAccountIdentifier(), nextDeviceId),
keysManager.deleteSingleUsePreKeys(account.getPhoneNumberIdentifier(), nextDeviceId),
account.getPhoneNumberIdentifierOptional()
.map(pni -> keysManager.deleteSingleUsePreKeys(pni, nextDeviceId))
.orElse(CompletableFuture.completedFuture(null)),
messagesManager.clear(account.getAccountIdentifier(), nextDeviceId))
.join();
account.addDevice(deviceSpec.toDevice(nextDeviceId, clock, account.getIdentityKey(IdentityType.ACI)));
account.addDevice(deviceSpec.toDevice(nextDeviceId, clock, account.getAccountIdentityKey()));
final List<TransactWriteItem> additionalWriteItems = new ArrayList<>(keysManager.buildWriteItemsForNewDevice(
account.getIdentifier(IdentityType.ACI),
account.getAccountIdentifier(),
account.getPhoneNumberIdentifierOptional(),
nextDeviceId,
deviceSpec.aciInfo().signedPreKey(),
@@ -18,7 +18,8 @@ public enum DeviceCapability {
ATTACHMENT_BACKFILL("attachmentBackfill", AccountCapabilityMode.PRIMARY_DEVICE, AccountCapabilityVisibility.SELF, false, false),
SPARSE_POST_QUANTUM_RATCHET("spqr", AccountCapabilityMode.ALL_DEVICES, AccountCapabilityVisibility.PUBLIC, true, true),
PROFILES_V2("profiles_v2", AccountCapabilityMode.ALL_DEVICES, AccountCapabilityVisibility.SELF, false, false),
USERNAME_CHANGE_SYNC_MESSAGE("usernameChangeSyncMessage", AccountCapabilityMode.ALL_DEVICES, AccountCapabilityVisibility.SELF, true, false);
USERNAME_CHANGE_SYNC_MESSAGE("usernameChangeSyncMessage", AccountCapabilityMode.ALL_DEVICES, AccountCapabilityVisibility.SELF, true, false),
OPTIONAL_PHONE_NUMBER("optionalPhoneNumber", AccountCapabilityMode.ALL_DEVICES, AccountCapabilityVisibility.SERVER, false, false);
public static final List<DeviceCapability> PUBLIC_VISIBLE_CAPABILITIES = Arrays.stream(DeviceCapability.values())
.filter(c -> c.accountCapabilityVisibility == AccountCapabilityVisibility.PUBLIC)
@@ -22,7 +22,7 @@ public record DeviceSpec(
boolean fetchesMessages,
Optional<ApnRegistrationId> apnRegistrationId,
Optional<GcmRegistrationId> gcmRegistrationId) {
public Device toDevice(final byte deviceId, final Clock clock, final IdentityKey aciIdentityKey) {
final long created = clock.millis();
@@ -92,6 +92,7 @@ enum DeviceCapability {
DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET = 7;
DEVICE_CAPABILITY_PROFILES_V2 = 8;
DEVICE_CAPABILITY_USERNAME_CHANGE_SYNC_MESSAGE = 9;
DEVICE_CAPABILITY_OPTIONAL_PHONE_NUMBER = 10;
}
message ZkCredential {
@@ -138,9 +138,11 @@ class DeviceControllerTest {
when(account.getNextDeviceId()).thenReturn(NEXT_DEVICE_ID);
when(account.getNumber()).thenReturn(AuthHelper.VALID_NUMBER);
when(account.getNumberOptional()).thenReturn(Optional.of((AuthHelper.VALID_NUMBER)));
when(account.getAccountIdentifier()).thenReturn(AuthHelper.VALID_UUID);
when(account.getIdentifier(IdentityType.ACI)).thenReturn(AuthHelper.VALID_UUID);
when(account.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI);
when(account.getPhoneNumberIdentifierOptional()).thenReturn(Optional.of(AuthHelper.VALID_PNI));
when(account.getIdentifier(IdentityType.PNI)).thenReturn(AuthHelper.VALID_PNI);
when(account.getPrimaryDevice()).thenReturn(primaryDevice);
when(account.getDevice(anyByte())).thenReturn(Optional.empty());
@@ -214,11 +216,12 @@ class DeviceControllerTest {
@ParameterizedTest
@MethodSource
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
void linkDeviceAtomic(final boolean fetchesMessages,
final Optional<ApnRegistrationId> apnRegistrationId,
final Optional<GcmRegistrationId> gcmRegistrationId,
final Optional<String> expectedApnsToken,
final Optional<String> expectedGcmToken) throws LinkDeviceTokenAlreadyUsedException {
void linkDeviceAtomic(final boolean withPhoneNumber,
final boolean fetchesMessages,
final Optional<ApnRegistrationId> apnRegistrationId,
final Optional<GcmRegistrationId> gcmRegistrationId,
final Optional<String> expectedApnsToken,
final Optional<String> expectedGcmToken) throws LinkDeviceTokenAlreadyUsedException {
final Device existingDevice = mock(Device.class);
when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);
@@ -238,8 +241,10 @@ class DeviceControllerTest {
pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);
final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());
when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));
when(account.getAccountIdentityKey()).thenReturn(aciIdentityKey);
when(account.getPhoneNumberIdentityKey()).thenReturn(
Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey()))
.filter(_ -> withPhoneNumber));
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
@@ -252,12 +257,15 @@ class DeviceControllerTest {
when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
final DeviceAttributes deviceAttributes = new DeviceAttributes(fetchesMessages, 1234, 5678, null,
DeviceCapability.CAPABILITIES_REQUIRED_FOR_NEW_DEVICES);
final EnumSet<DeviceCapability> capabilities = EnumSet.copyOf(DeviceCapability.CAPABILITIES_REQUIRED_FOR_NEW_DEVICES);
capabilities.add(DeviceCapability.OPTIONAL_PHONE_NUMBER);
final DeviceAttributes deviceAttributes = new DeviceAttributes(fetchesMessages, 1234, withPhoneNumber ? 5678 : null, null,
capabilities);
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
deviceAttributes,
new DeviceActivationRequest(aciSignedPreKey, Optional.of(pniSignedPreKey), aciPqLastResortPreKey, Optional.of(pniPqLastResortPreKey), apnRegistrationId, gcmRegistrationId));
new DeviceActivationRequest(aciSignedPreKey, Optional.of(pniSignedPreKey).filter(_ -> withPhoneNumber), aciPqLastResortPreKey, Optional.of(pniPqLastResortPreKey).filter(_ -> withPhoneNumber), apnRegistrationId, gcmRegistrationId));
final LinkDeviceResponse response = resources.getJerseyTest()
.target("/v1/devices/link")
@@ -286,10 +294,14 @@ class DeviceControllerTest {
final String gcmToken = "gcm-token";
return Stream.of(
Arguments.of(true, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),
Arguments.of(false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(false, Optional.empty(), Optional.of(new GcmRegistrationId(gcmToken)), Optional.empty(), Optional.of(gcmToken))
Arguments.of(true, true, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),
Arguments.of(true, false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(true, false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(true, false, Optional.empty(), Optional.of(new GcmRegistrationId(gcmToken)), Optional.empty(), Optional.of(gcmToken)),
Arguments.of(false, true, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),
Arguments.of(false, false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(false, false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),
Arguments.of(false, false, Optional.empty(), Optional.of(new GcmRegistrationId(gcmToken)), Optional.empty(), Optional.of(gcmToken))
);
}
@@ -320,8 +332,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(account.hasCapability(capability)).thenReturn(accountHasCapability);
when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
@@ -376,8 +388,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));
@@ -422,8 +434,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
new DeviceAttributes(false, 1234, 5678, null, null),
@@ -458,8 +470,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
@@ -507,8 +519,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(commands.get(anyString())).thenReturn("");
@@ -558,8 +570,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.token(),
new DeviceAttributes(fetchesMessages, 1234, 5678, null, null),
@@ -599,9 +611,8 @@ class DeviceControllerTest {
final Device existingDevice = mock(Device.class);
when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);
when(account.getDevices()).thenReturn(List.of(existingDevice));
when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey);
when(account.getAccountIdentityKey()).thenReturn(aciIdentityKey);
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(pniIdentityKey));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
new DeviceAttributes(true, 1234, 5678, null, null),
@@ -637,6 +648,37 @@ class DeviceControllerTest {
);
}
@Test
void linkDeviceSpuriousPniMaterial() {
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));
when(accountsManager.checkDeviceLinkingToken(any())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());
final IdentityKey pniIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());
final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);
final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);
final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);
final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);
when(account.getAccountIdentityKey()).thenReturn(aciIdentityKey);
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.empty());
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
new DeviceAttributes(true, 1234, 5678, null, null),
new DeviceActivationRequest(aciSignedPreKey, Optional.of(pniSignedPreKey), aciPqLastResortPreKey, Optional.of(pniPqLastResortPreKey), Optional.empty(), Optional.empty()));
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(422, response.getStatus());
}
}
@Test
void linkDeviceAtomicMissingCapabilities() {
final ECSignedPreKey aciSignedPreKey;
@@ -658,8 +700,8 @@ class DeviceControllerTest {
when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);
when(account.getDevices()).thenReturn(List.of(existingDevice));
when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));
when(account.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
@@ -677,6 +719,46 @@ class DeviceControllerTest {
}
}
@Test
void linkDeviceAtomicMissingOptionalPhoneNumberCapability() {
final ECSignedPreKey aciSignedPreKey;
final ECSignedPreKey pniSignedPreKey;
final KEMSignedPreKey aciPqLastResortPreKey;
final KEMSignedPreKey pniPqLastResortPreKey;
final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);
pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);
aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);
pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));
final Device existingDevice = mock(Device.class);
when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);
when(account.getDevices()).thenReturn(List.of(existingDevice));
when(account.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentifierOptional()).thenReturn(Optional.empty());
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
new DeviceAttributes(true, 1234, 5678, null, DeviceCapability.CAPABILITIES_REQUIRED_FOR_NEW_DEVICES),
new DeviceActivationRequest(aciSignedPreKey, Optional.of(pniSignedPreKey), aciPqLastResortPreKey, Optional.of(pniPqLastResortPreKey), Optional.empty(), Optional.empty()));
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(409, response.getStatus());
}
}
@ParameterizedTest
@MethodSource
void linkDeviceAtomicInvalidSignature(final IdentityKey aciIdentityKey,
@@ -691,8 +773,8 @@ class DeviceControllerTest {
final Device existingDevice = mock(Device.class);
when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);
when(account.getDevices()).thenReturn(List.of(existingDevice));
when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey);
when(account.getAccountIdentityKey()).thenReturn(aciIdentityKey);
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(pniIdentityKey));
when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));
@@ -752,8 +834,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
new DeviceAttributes(false, 1234, 5678, TestRandomUtil.nextBytes(512), null),
@@ -786,8 +868,8 @@ class DeviceControllerTest {
final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);
final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());
when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);
when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));
when(account.getAccountIdentityKey()).thenReturn(aciIdentityKey);
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> {
final Account a = accountsManager.getByAccountIdentifier(invocation.getArgument(0)).orElseThrow();
@@ -848,8 +930,8 @@ class DeviceControllerTest {
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.getAccountIdentityKey()).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));
when(account.getPhoneNumberIdentityKey()).thenReturn(Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())));
final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token",
null,
@@ -654,46 +654,6 @@ class RegistrationControllerTest {
}
@ParameterizedTest
@MethodSource
void atomicAccountCreationSuccess(final RegistrationRequest registrationRequest,
final IdentityKey expectedAciIdentityKey,
final IdentityKey expectedPniIdentityKey,
final DeviceSpec expectedDeviceSpec) {
final UUID accountIdentifier = UUID.randomUUID();
final UUID phoneNumberIdentifier = UUID.randomUUID();
final Device device = mock(Device.class);
final Account account = MockUtils.buildMock(Account.class, a -> {
when(a.getAccountIdentifier()).thenReturn(accountIdentifier);
when(a.getPhoneNumberIdentifierOptional()).thenReturn(Optional.of(phoneNumberIdentifier));
when(a.getPrimaryDevice()).thenReturn(device);
});
when(accountsManager.create(any(), any(), any(), any(), any(), any()))
.thenReturn(account);
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));
try (Response response = request.post(Entity.json(registrationRequest))) {
assertEquals(200, response.getStatus());
final AccountIdentityResponse identityResponse = response.readEntity(AccountIdentityResponse.class);
assertEquals(accountIdentifier, identityResponse.uuid());
}
verify(accountsManager).create(
eq(NUMBER),
argThat(attributes -> accountAttributesEqual(attributes, registrationRequest.accountAttributes())),
eq(expectedAciIdentityKey),
eq(expectedPniIdentityKey),
eq(expectedDeviceSpec),
any());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void reregistrationFlag(final boolean accountExists) {
@@ -1177,6 +1137,45 @@ class RegistrationControllerTest {
&& Arrays.equals(a.recoveryPassword().orElse(null), b.recoveryPassword().orElse(null));
}
@ParameterizedTest
@MethodSource
void atomicAccountCreationSuccess(final RegistrationRequest registrationRequest,
final IdentityKey expectedAciIdentityKey,
final IdentityKey expectedPniIdentityKey,
final DeviceSpec expectedDeviceSpec) throws InterruptedException {
final UUID accountIdentifier = UUID.randomUUID();
final UUID phoneNumberIdentifier = UUID.randomUUID();
final Device device = mock(Device.class);
final Account account = mock(Account.class);
when(account.getAccountIdentifier()).thenReturn(accountIdentifier);
when(account.getPhoneNumberIdentifierOptional()).thenReturn(Optional.of(phoneNumberIdentifier));
when(account.getPrimaryDevice()).thenReturn(device);
when(accountsManager.create(any(), any(), any(), any(), any(), any()))
.thenReturn(account);
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));
try (Response response = request.post(Entity.json(registrationRequest))) {
assertEquals(200, response.getStatus());
final AccountIdentityResponse identityResponse = response.readEntity(AccountIdentityResponse.class);
assertEquals(accountIdentifier, identityResponse.uuid());
}
verify(accountsManager).create(
eq(NUMBER),
argThat(attributes -> accountAttributesEqual(attributes, registrationRequest.accountAttributes())),
eq(expectedAciIdentityKey),
eq(expectedPniIdentityKey),
eq(expectedDeviceSpec),
any());
}
private static List<Arguments> atomicAccountCreationSuccess() {
final IdentityKey aciIdentityKey;
final IdentityKey pniIdentityKey;
@@ -1203,13 +1202,11 @@ class RegistrationControllerTest {
final Set<DeviceCapability> deviceCapabilities = DeviceCapability.CAPABILITIES_REQUIRED_FOR_NEW_DEVICES;
final AccountAttributes fetchesMessagesAccountAttributes =
new AccountAttributes(true, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities,
null)
new AccountAttributes(true, registrationId, pniRegistrationId, deviceName, null, true, deviceCapabilities, null)
.setUnidentifiedAccessKey(TestRandomUtil.nextBytes(16));
final AccountAttributes pushAccountAttributes =
new AccountAttributes(false, registrationId, pniRegistrationId, "test".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities,
null)
new AccountAttributes(false, registrationId, pniRegistrationId, deviceName, null, true, deviceCapabilities, null)
.setUnidentifiedAccessKey(TestRandomUtil.nextBytes(16));
final String apnsToken = "apns-token";
@@ -994,15 +994,18 @@ class AccountsManagerTest {
assertEquals(hasStorage, account.hasCapability(DeviceCapability.STORAGE));
}
@Test
void testAddDevice() throws LinkDeviceTokenAlreadyUsedException {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testAddDevice(boolean accountHasPhoneNumber) throws LinkDeviceTokenAlreadyUsedException {
final String phoneNumber =
PhoneNumberUtil.getInstance().format(PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164);
final Account account = AccountsHelper.generateTestAccount(phoneNumber, List.of(generateTestDevice(CLOCK.millis())));
final UUID aci = account.getIdentifier(IdentityType.ACI);
final UUID pni = account.getIdentifier(IdentityType.PNI);
final Account account = accountHasPhoneNumber
? AccountsHelper.generateTestAccount(phoneNumber, List.of(generateTestDevice(CLOCK.millis())))
: AccountsHelper.generateTestAccountNoPhoneNumber(List.of(generateTestDevice(CLOCK.millis())));
final UUID aci = account.getAccountIdentifier();
final Optional<UUID> maybePni = account.getPhoneNumberIdentifierOptional();
account.setIdentityKey(new IdentityKey(ECKeyPair.generate().getPublicKey()));
final byte nextDeviceId = account.getNextDeviceId();
@@ -1027,39 +1030,42 @@ class AccountsManagerTest {
CLOCK.pin(CLOCK.instant().plusSeconds(60));
final Pair<Account, Device> updatedAccountAndDevice = accountsManager.addDevice(aci, new DeviceSpec(
final Pair<Account, Device> updatedAccountAndDevice = accountsManager.addDevice(
aci,
new DeviceSpec(
deviceNameCiphertext,
password,
signalAgent,
deviceCapabilities,
new DeviceIdentityInfo(aciRegistrationId, aciSignedPreKey, aciPqLastResortPreKey),
Optional.of(new DeviceIdentityInfo(pniRegistrationId, pniSignedPreKey, pniPqLastResortPreKey)),
Optional.of(new DeviceIdentityInfo(pniRegistrationId, pniSignedPreKey, pniPqLastResortPreKey)).filter(_ -> accountHasPhoneNumber),
true,
Optional.empty(),
Optional.empty()),
accountsManager.generateLinkDeviceToken(aci));
verify(keysManager).deleteSingleUsePreKeys(aci, nextDeviceId);
verify(keysManager).deleteSingleUsePreKeys(pni, nextDeviceId);
maybePni.ifPresent(pni -> verify(keysManager).deleteSingleUsePreKeys(pni, nextDeviceId));
verify(messagesManager).clear(aci, nextDeviceId);
verify(keysManager).buildWriteItemsForNewDevice(
aci,
Optional.of(pni),
maybePni,
nextDeviceId,
aciSignedPreKey,
Optional.of(pniSignedPreKey),
maybePni.map(_ -> pniSignedPreKey),
aciPqLastResortPreKey,
Optional.of(pniPqLastResortPreKey));
maybePni.map(_ ->pniPqLastResortPreKey));
verifyNoMoreInteractions(keysManager);
final Device device = updatedAccountAndDevice.second();
assertEquals(deviceNameCiphertext, device.getName());
assertTrue(device.getAuthTokenHash().verify(password));
assertEquals(signalAgent, device.getUserAgent());
assertEquals(Collections.emptySet(), device.getCapabilities());
assertEquals(aciRegistrationId, device.getRegistrationId(IdentityType.ACI));
assertEquals(pniRegistrationId, device.getRegistrationId(IdentityType.PNI));
assertEquals(aciRegistrationId, device.getAccountRegistrationId());
assertEquals(accountHasPhoneNumber ? Optional.of(pniRegistrationId) : Optional.empty(), device.getPhoneNumberIdentityRegistrationId());
assertTrue(device.getFetchesMessages());
assertNull(device.getApnId());
assertNull(device.getGcmId());
@@ -66,6 +66,14 @@ public class AccountsHelper {
return account;
}
public static Account generateTestAccountNoPhoneNumber(List<Device> devices) {
final Account account = new Account();
account.setAccountIdentifier(UUID.randomUUID());
devices.forEach(account::addDevice);
return account;
}
public static void setupMockUpdate(final AccountsManager mockAccountsManager) {
setupMockUpdate(mockAccountsManager, true);
}