diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java index 2740691f4..8ab61bff4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java @@ -18,6 +18,7 @@ package org.whispersystems.textsecuregcm.controllers; import com.codahale.metrics.annotation.Timed; import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.auth.Auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials; @@ -38,6 +39,8 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager; import org.whispersystems.textsecuregcm.storage.PendingDevicesManager; import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.VerificationCode; +import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; +import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; import javax.validation.Valid; import javax.ws.rs.Consumes; @@ -57,12 +60,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import io.dropwizard.auth.Auth; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - @Path("/v1/devices") public class DeviceController { @@ -249,6 +246,10 @@ public class DeviceController { private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities, String userAgent) { boolean isDowngrade = false; + if (account.isGv1MigrationSupported() && !capabilities.isGv1Migration()) { + isDowngrade = true; + } + if (account.isGroupsV2Supported()) { try { switch (UserAgentUtil.parseUserAgentString(userAgent).getPlatform()) { 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 bfb8a8167..b6593ac6a 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 getMasterDevice().map(Device::getCapabilities).map(Device.DeviceCapabilities::isTransfer).orElse(false); } + public boolean isGv1MigrationSupported() { + return devices.stream().allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isGv1Migration()); + } + public boolean isEnabled() { return getMasterDevice().map(Device::isEnabled).orElse(false); } 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 2be523e2d..ef3153560 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java @@ -294,14 +294,18 @@ public class Device { @JsonProperty private boolean transfer; + @JsonProperty("gv1-migration") + private boolean gv1Migration; + public DeviceCapabilities() {} - public DeviceCapabilities(boolean gv2, final boolean gv2_2, final boolean gv2_3, boolean storage, boolean transfer) { - this.gv2 = gv2; - this.gv2_2 = gv2_2; - this.gv2_3 = gv2_3; - this.storage = storage; + public DeviceCapabilities(boolean gv2, final boolean gv2_2, final boolean gv2_3, boolean storage, boolean transfer, boolean gv1Migration) { + this.gv2 = gv2; + this.gv2_2 = gv2_2; + this.gv2_3 = gv2_3; + this.storage = storage; this.transfer = transfer; + this.gv1Migration = gv1Migration; } public boolean isGv2() { @@ -323,6 +327,9 @@ public class Device { public boolean isTransfer() { return transfer; } - } + public boolean isGv1Migration() { + return gv1Migration; + } + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java index 4c35ae930..7583df9c8 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java @@ -64,7 +64,7 @@ public class DeviceTest { @Test @Parameters(method = "argumentsForTestIsGroupsV2Supported") public void testIsGroupsV2Supported(final boolean master, final String apnId, final boolean gv2Capability, final boolean gv2_2Capability, final boolean gv2_3Capability, final boolean expectGv2Supported) { - final Device.DeviceCapabilities capabilities = new Device.DeviceCapabilities(gv2Capability, gv2_2Capability, gv2_3Capability, false, false); + final Device.DeviceCapabilities capabilities = new Device.DeviceCapabilities(gv2Capability, gv2_2Capability, gv2_3Capability, false, false, false); final Device device = new Device(master ? 1 : 2, "test", "auth-token", "salt", "signaling-key", null, apnId, null, false, 1, null, 0, 0, "user-agent", 0, capabilities); assertEquals(expectGv2Supported, device.isGroupsV2Supported()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java index fcc42a678..e2c4433b0 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java @@ -17,6 +17,8 @@ package org.whispersystems.textsecuregcm.tests.controllers; import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; import junitparams.JUnitParamsRunner; import junitparams.Parameters; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; @@ -50,11 +52,13 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit.ResourceTestRule; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; @RunWith(JUnitParamsRunner.class) public class DeviceControllerTest { @@ -122,6 +126,7 @@ public class DeviceControllerTest { when(account.getAuthenticatedDevice()).thenReturn(Optional.of(masterDevice)); when(account.isEnabled()).thenReturn(false); when(account.isGroupsV2Supported()).thenReturn(true); + when(account.isGv1MigrationSupported()).thenReturn(true); when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(new StoredVerificationCode("5678901", System.currentTimeMillis(), null))); when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(new StoredVerificationCode("1112223", System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(31), null))); @@ -228,7 +233,7 @@ public class DeviceControllerTest { @Test @Parameters(method = "argumentsForDeviceDowngradeCapabilitiesTest") public void deviceDowngradeCapabilitiesTest(final String userAgent, final boolean gv2, final boolean gv2_2, final boolean gv2_3, final int expectedStatus) throws Exception { - Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(gv2, gv2_2, gv2_3, true, false); + Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(gv2, gv2_2, gv2_3, true, false, true); AccountAttributes accountAttributes = new AccountAttributes("keykeykeykey", false, 1234, null, null, null, null, true, deviceCapabilities); Response response = resources.getJerseyTest() .target("/v1/devices/5678901") @@ -265,4 +270,30 @@ public class DeviceControllerTest { new Object[] { "Old client with unparsable UA", false, false, true, 409 } }; } + + @Test + public void deviceDowngradeGv1MigrationTest() { + Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(true, true, true, true, false, false); + AccountAttributes accountAttributes = new AccountAttributes("keykeykeykey", false, 1234, null, null, null, null, true, deviceCapabilities); + Response response = resources.getJerseyTest() + .target("/v1/devices/5678901") + .request() + .header("authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, "password1")) + .header("user-agent", "Signal-Android/4.68.3 Android/25") + .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(409); + + deviceCapabilities = new Device.DeviceCapabilities(true, true, true, true, false, true); + accountAttributes = new AccountAttributes("keykeykeykey", false, 1234, null, null, null, null, true, deviceCapabilities); + response = resources.getJerseyTest() + .target("/v1/devices/5678901") + .request() + .header("authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, "password1")) + .header("user-agent", "Signal-Android/4.68.3 Android/25") + .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(200); + + } } 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 cd9f5f2e0..8ee45505d 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 @@ -2,6 +2,8 @@ package org.whispersystems.textsecuregcm.tests.controllers; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; import org.junit.Before; import org.junit.Rule; @@ -42,15 +44,23 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit.ResourceTestRule; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson; import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; @@ -85,13 +95,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, false, false, 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, false, false, true, true, false))); }}; 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, false, false, 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, false, false, 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, 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, false, false, true, false, 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, false, false, true, false, 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, 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 8951b62c3..02463eaa7 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 @@ -29,6 +29,10 @@ public class AccountTest { private final Device gv2IncapableDevice = mock(Device.class); private final Device gv2IncapableExpiredDevice = mock(Device.class); + private final Device gv1MigrationCapableDevice = mock(Device.class); + private final Device gv1MigrationIncapableDevice = mock(Device.class); + private final Device gv1MigrationIncapableExpiredDevice = mock(Device.class); + @Before public void setup() { when(oldMasterDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366)); @@ -62,6 +66,10 @@ public class AccountTest { when(gv2IncapableExpiredDevice.isGroupsV2Supported()).thenReturn(false); when(gv2IncapableExpiredDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)); when(gv2IncapableExpiredDevice.isEnabled()).thenReturn(false); + + when(gv1MigrationCapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(true, true, true, true, true, true)); + when(gv1MigrationIncapableDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(true, true, true, true, true, false)); + when(gv1MigrationIncapableExpiredDevice.getCapabilities()).thenReturn(new Device.DeviceCapabilities(true, true, true, true, true, false)); } @Test @@ -177,4 +185,11 @@ public class AccountTest { assertTrue(new Account("+18005551234", UUID.randomUUID(), Set.of(gv2CapableDevice, gv2IncapableExpiredDevice), "1234".getBytes(StandardCharsets.UTF_8)).isGroupsV2Supported()); assertFalse(new Account("+18005551234", UUID.randomUUID(), Set.of(gv2CapableDevice, gv2IncapableDevice), "1234".getBytes(StandardCharsets.UTF_8)).isGroupsV2Supported()); } + + @Test + public void isGv1MigrationSupported() { + assertTrue(new Account("+18005551234", UUID.randomUUID(), Set.of(gv1MigrationCapableDevice), "1234".getBytes(StandardCharsets.UTF_8)).isGv1MigrationSupported()); + assertFalse(new Account("+18005551234", UUID.randomUUID(), Set.of(gv1MigrationCapableDevice, gv1MigrationIncapableDevice), "1234".getBytes(StandardCharsets.UTF_8)).isGv1MigrationSupported()); + assertFalse(new Account("+18005551234", UUID.randomUUID(), Set.of(gv1MigrationCapableDevice, gv1MigrationIncapableExpiredDevice), "1234".getBytes(StandardCharsets.UTF_8)).isGv1MigrationSupported()); + } } 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 dce659f0b..490c48adb 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 @@ -286,7 +286,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(), 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(), random.nextBoolean(), random.nextBoolean())); } private Account generateAccount(String number, UUID uuid) {