From 001a9310c32bc0e92af9033d0b5d0cadde149ec3 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Tue, 12 May 2020 12:23:18 -0400 Subject: [PATCH] Support device transfers (SERVER-41, SERVER-42) (#32) This change introduces a `transfer` device capability and account creation argument in support of the iOS device transfer effort. --- .../controllers/AccountController.java | 5 ++ .../textsecuregcm/storage/Account.java | 4 ++ .../textsecuregcm/storage/Device.java | 16 ++++-- .../controllers/AccountControllerTest.java | 55 +++++++++++++++++++ .../controllers/MessageControllerTest.java | 8 +-- .../tests/storage/AccountTest.java | 55 ++++++++++++++++++- .../tests/storage/AccountsTest.java | 2 +- 7 files changed, 133 insertions(+), 12 deletions(-) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 90e526aa8..03691871b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -254,6 +254,7 @@ public class AccountController { public AccountCreationResult verifyAccount(@PathParam("verification_code") String verificationCode, @HeaderParam("Authorization") String authorizationHeader, @HeaderParam("X-Signal-Agent") String userAgent, + @QueryParam("transfer") Optional availableForTransfer, @Valid AccountAttributes accountAttributes) throws RateLimitExceededException { @@ -296,6 +297,10 @@ public class AccountController { rateLimiters.getPinLimiter().clear(number); } + if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) { + throw new WebApplicationException(Response.status(409).build()); + } + Account account = createAccount(number, password, userAgent, accountAttributes); metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index d0a31eae6..729efa95a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -150,6 +150,10 @@ public class Account implements Principal { return devices.stream().anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStorage()); } + public boolean isTransferSupported() { + return getMasterDevice().map(Device::getCapabilities).map(Device.DeviceCapabilities::isTransfer).orElse(false); + } + public boolean isEnabled() { return getMasterDevice().isPresent() && diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java index 74102629f..190967126 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java @@ -276,12 +276,16 @@ public class Device { @JsonProperty private boolean storage; + @JsonProperty + private boolean transfer; + public DeviceCapabilities() {} - public DeviceCapabilities(boolean uuid, boolean gv2, boolean storage) { - this.uuid = uuid; - this.gv2 = gv2; - this.storage = storage; + public DeviceCapabilities(boolean uuid, boolean gv2, boolean storage, boolean transfer) { + this.uuid = uuid; + this.gv2 = gv2; + this.storage = storage; + this.transfer = transfer; } public boolean isUuid() { @@ -295,6 +299,10 @@ public class Device { public boolean isStorage() { return storage; } + + public boolean isTransfer() { + return transfer; + } } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java index 13c684bb0..f65a37f5d 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java @@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.storage.AbusiveHostRule; import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.MessagesManager; import org.whispersystems.textsecuregcm.storage.PaymentAddress; import org.whispersystems.textsecuregcm.storage.PaymentAddressList; @@ -53,14 +54,18 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.IOException; import java.security.SecureRandom; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.doThrow; @@ -83,6 +88,7 @@ public class AccountControllerTest { private static final String SENDER_PREAUTH = "+14157777777"; private static final String SENDER_REG_LOCK = "+14158888888"; private static final String SENDER_HAS_STORAGE = "+14159999999"; + private static final String SENDER_TRANSFER = "+14151111112"; private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID(); @@ -114,6 +120,7 @@ public class AccountControllerTest { private Account senderPinAccount = mock(Account.class); private Account senderRegLockAccount = mock(Account.class); private Account senderHasStorage = mock(Account.class); + private Account senderTransfer = mock(Account.class); private RecaptchaClient recaptchaClient = mock(RecaptchaClient.class); private GCMSender gcmSender = mock(GCMSender.class); private APNSender apnSender = mock(APNSender.class); @@ -180,6 +187,7 @@ public class AccountControllerTest { when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(Optional.of(new StoredVerificationCode("444444", System.currentTimeMillis(), null))); when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(Optional.of(new StoredVerificationCode("555555", System.currentTimeMillis(), "validchallenge"))); when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(Optional.of(new StoredVerificationCode("666666", System.currentTimeMillis(), null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), null))); when(accountsManager.get(eq(SENDER_PIN))).thenReturn(Optional.of(senderPinAccount)); when(accountsManager.get(eq(SENDER_REG_LOCK))).thenReturn(Optional.of(senderRegLockAccount)); @@ -188,6 +196,7 @@ public class AccountControllerTest { when(accountsManager.get(eq(SENDER_OLD))).thenReturn(Optional.empty()); when(accountsManager.get(eq(SENDER_PREAUTH))).thenReturn(Optional.empty()); when(accountsManager.get(eq(SENDER_HAS_STORAGE))).thenReturn(Optional.of(senderHasStorage)); + when(accountsManager.get(eq(SENDER_TRANSFER))).thenReturn(Optional.of(senderTransfer)); when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("n00bkiller"))).thenReturn(true); when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("takenusername"))).thenReturn(false); @@ -747,6 +756,52 @@ public class AccountControllerTest { } } + @Test + public void testVerifyTransferSupported() { + when(senderTransfer.isTransferSupported()).thenReturn(true); + + final Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/code/%s", "1234")) + .queryParam("transfer", true) + .request() + .header("Authorization", AuthHelper.getAuthHeader(SENDER_TRANSFER, "bar")) + .put(Entity.entity(new AccountAttributes("keykeykeykey", false, 2222, null), + MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(409); + } + + @Test + public void testVerifyTransferNotSupported() { + when(senderTransfer.isTransferSupported()).thenReturn(false); + + final Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/code/%s", "1234")) + .queryParam("transfer", true) + .request() + .header("Authorization", AuthHelper.getAuthHeader(SENDER_TRANSFER, "bar")) + .put(Entity.entity(new AccountAttributes("keykeykeykey", false, 2222, null), + MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void testVerifyTransferSupportedNotRequested() { + when(senderTransfer.isTransferSupported()).thenReturn(true); + + final Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/code/%s", "1234")) + .request() + .header("Authorization", AuthHelper.getAuthHeader(SENDER_TRANSFER, "bar")) + .put(Entity.entity(new AccountAttributes("keykeykeykey", false, 2222, null), + MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(200); + } @Test public void testSetPin() throws Exception { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/MessageControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/MessageControllerTest.java index 9e5d0f3bc..c3674a72b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/MessageControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/MessageControllerTest.java @@ -85,13 +85,13 @@ public class MessageControllerTest { @Before public void setup() throws Exception { Set singleDeviceList = new HashSet() {{ - add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true))); + add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true, true))); }}; Set multiDeviceList = new HashSet() {{ - add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true))); - add(new Device(2, null, "foo", "bar", "baz", "isgcm", null, null, false, 333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true))); - add(new Device(3, null, "foo", "bar", "baz", "isgcm", null, null, false, 444, null, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(false, false, false))); + add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true, false))); + add(new Device(2, null, "foo", "bar", "baz", "isgcm", null, null, false, 333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(true, true, true, false))); + add(new Device(3, null, "foo", "bar", "baz", "isgcm", null, null, false, 444, null, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31), System.currentTimeMillis(), "Test", 0, new Device.DeviceCapabilities(false, false, false, false))); }}; Account singleDeviceAccount = new Account(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, singleDeviceList, "1234".getBytes()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java index af0be15fc..2041ec7ba 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java @@ -5,6 +5,7 @@ import org.junit.Test; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; +import java.util.Collections; import java.util.HashSet; import java.util.Optional; import java.util.UUID; @@ -49,15 +50,15 @@ public class AccountTest { when(oldSecondaryDevice.isEnabled()).thenReturn(false); when(oldSecondaryDevice.getId()).thenReturn(2L); - when(uuidCapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(true, true, true)); + when(uuidCapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(true, true, true, true)); when(uuidCapableDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)); when(uuidCapableDevice.isEnabled()).thenReturn(true); - when(uuidIncapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(false, false, false)); + when(uuidIncapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(false, false, false, false)); when(uuidIncapableDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)); when(uuidIncapableDevice.isEnabled()).thenReturn(true); - when(uuidIncapableExpiredDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(false, false, false)); + when(uuidIncapableExpiredDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(false, false, false, false)); when(uuidIncapableExpiredDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)); when(uuidIncapableExpiredDevice.isEnabled()).thenReturn(false); } @@ -117,4 +118,52 @@ public class AccountTest { assertTrue(uuidCapableWithExpiredIncapable.isUuidAddressingSupported()); } + @Test + public void testIsTransferSupported() { + final Device transferCapableMasterDevice = mock(Device.class); + final Device nonTransferCapableMasterDevice = mock(Device.class); + final Device transferCapableLinkedDevice = mock(Device.class); + + final Device.DeviceCapabilities transferCapabilities = mock(Device.DeviceCapabilities.class); + final Device.DeviceCapabilities nonTransferCapabilities = mock(Device.DeviceCapabilities.class); + + when(transferCapableMasterDevice.getId()).thenReturn(1L); + when(transferCapableMasterDevice.isMaster()).thenReturn(true); + when(transferCapableMasterDevice.getCapabilities()).thenReturn(transferCapabilities); + + when(nonTransferCapableMasterDevice.getId()).thenReturn(1L); + when(nonTransferCapableMasterDevice.isMaster()).thenReturn(true); + when(nonTransferCapableMasterDevice.getCapabilities()).thenReturn(nonTransferCapabilities); + + when(transferCapableLinkedDevice.getId()).thenReturn(2L); + when(transferCapableLinkedDevice.isMaster()).thenReturn(false); + when(transferCapableLinkedDevice.getCapabilities()).thenReturn(transferCapabilities); + + when(transferCapabilities.isTransfer()).thenReturn(true); + when(nonTransferCapabilities.isTransfer()).thenReturn(false); + + { + final Account transferableMasterAccount = + new Account("+14152222222", UUID.randomUUID(), Collections.singleton(transferCapableMasterDevice), "1234".getBytes()); + + assertTrue(transferableMasterAccount.isTransferSupported()); + } + + { + final Account nonTransferableMasterAccount = + new Account("+14152222222", UUID.randomUUID(), Collections.singleton(nonTransferCapableMasterDevice), "1234".getBytes()); + + assertFalse(nonTransferableMasterAccount.isTransferSupported()); + } + + { + final Account transferableLinkedAccount = new Account("+14152222222", UUID.randomUUID(), new HashSet<>() {{ + add(nonTransferCapableMasterDevice); + add(transferCapableLinkedDevice); + }}, "1234".getBytes()); + + assertFalse(transferableLinkedAccount.isTransferSupported()); + } + } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountsTest.java index 969bdb804..23955499c 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountsTest.java @@ -270,7 +270,7 @@ public class AccountsTest { private Device generateDevice(long id) { Random random = new Random(System.currentTimeMillis()); SignedPreKey signedPreKey = new SignedPreKey(random.nextInt(), "testPublicKey-" + random.nextInt(), "testSignature-" + random.nextInt()); - return new Device(id, "testName-" + random.nextInt(), "testAuthToken-" + random.nextInt(), "testSalt-" + random.nextInt(), null, "testGcmId-" + random.nextInt(), "testApnId-" + random.nextInt(), "testVoipApnId-" + random.nextInt(), random.nextBoolean(), random.nextInt(), signedPreKey, random.nextInt(), random.nextInt(), "testUserAgent-" + random.nextInt() , 0, new Device.DeviceCapabilities(random.nextBoolean(), random.nextBoolean(), random.nextBoolean())); + return new Device(id, "testName-" + random.nextInt(), "testAuthToken-" + random.nextInt(), "testSalt-" + random.nextInt(), null, "testGcmId-" + random.nextInt(), "testApnId-" + random.nextInt(), "testVoipApnId-" + random.nextInt(), random.nextBoolean(), random.nextInt(), signedPreKey, random.nextInt(), random.nextInt(), "testUserAgent-" + random.nextInt() , 0, new Device.DeviceCapabilities(random.nextBoolean(), random.nextBoolean(), random.nextBoolean(), random.nextBoolean())); } private Account generateAccount(String number, UUID uuid) {