diff --git a/integration-tests/src/main/java/org/signal/integration/TestUser.java b/integration-tests/src/main/java/org/signal/integration/TestUser.java index 8a7dbeff..56fd9a72 100644 --- a/integration-tests/src/main/java/org/signal/integration/TestUser.java +++ b/integration-tests/src/main/java/org/signal/integration/TestUser.java @@ -126,7 +126,7 @@ public class TestUser { } public AccountAttributes accountAttributes() { - return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", true, new Device.DeviceCapabilities(false, false, false, false)) + return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", true, new Device.DeviceCapabilities(false, false, false, false, false)) .withUnidentifiedAccessKey(unidentifiedAccessKey) .withRecoveryPassword(registrationPassword); } 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 af54cca8..483ec5cd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java @@ -392,7 +392,10 @@ public class DeviceController { } private static boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) { - return account.isDeleteSyncSupported() && !capabilities.deleteSync(); + boolean isDowngrade = false; + isDowngrade |= account.isDeleteSyncSupported() && !capabilities.deleteSync(); + isDowngrade |= account.isVersionedExpirationTimerSupported() && !capabilities.versionedExpirationTimer(); + return isDowngrade; } private static String getUsedTokenKey(final String token) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java index 55192e1e..a6502660 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java @@ -8,11 +8,13 @@ package org.whispersystems.textsecuregcm.entities; import org.whispersystems.textsecuregcm.storage.Account; public record UserCapabilities( - // TODO: Remove the paymentActivation capability entirely sometime soon after 2024-06-30 + // TODO: Remove the paymentActivation capability entirely sometime soon after 2024-10-07 boolean paymentActivation, - boolean deleteSync) { + boolean deleteSync, + boolean versionedExpirationTimer) { public static UserCapabilities createForAccount(final Account account) { - return new UserCapabilities(true, account.isDeleteSyncSupported()); + return new UserCapabilities(true, account.isDeleteSyncSupported(), + account.isVersionedExpirationTimerSupported()); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java index 5f687465..4cb5aaa5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java @@ -198,7 +198,8 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase { request.getStorage(), request.getTransfer(), request.getPaymentActivation(), - request.getDeleteSync()))))) + request.getDeleteSync(), + request.getVersionedExpirationTimer()))))) .thenReturn(SetCapabilitiesResponse.newBuilder().build()); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java index c4877541..09be841f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -84,6 +84,7 @@ public class ProfileGrpcHelper { return UserCapabilities.newBuilder() .setPaymentActivation(capabilities.paymentActivation()) .setDeleteSync(capabilities.deleteSync()) + .setVersionedExpirationTimer(capabilities.versionedExpirationTimer()) .build(); } 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 5cab0324..fd459522 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -296,6 +296,10 @@ public class Account { return allDevicesHaveCapability(DeviceCapabilities::deleteSync); } + public boolean isVersionedExpirationTimerSupported() { + return allDevicesHaveCapability(DeviceCapabilities::versionedExpirationTimer); + } + private boolean allDevicesHaveCapability(final Predicate predicate) { requireNotStale(); 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 ee1eafc0..cfec4009 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java @@ -232,6 +232,7 @@ public class Device { return this.userAgent; } - public record DeviceCapabilities(boolean storage, boolean transfer, boolean paymentActivation, boolean deleteSync) { + public record DeviceCapabilities(boolean storage, boolean transfer, boolean paymentActivation, boolean deleteSync, + boolean versionedExpirationTimer) { } } diff --git a/service/src/main/proto/org/signal/chat/device.proto b/service/src/main/proto/org/signal/chat/device.proto index a6541a2f..9132c5d6 100644 --- a/service/src/main/proto/org/signal/chat/device.proto +++ b/service/src/main/proto/org/signal/chat/device.proto @@ -149,6 +149,7 @@ message SetCapabilitiesRequest { bool transfer = 2; bool paymentActivation = 3; bool deleteSync = 4; + bool versionedExpirationTimer = 5; } message SetCapabilitiesResponse {} diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index 790d946b..ff778e56 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -326,6 +326,10 @@ message UserCapabilities { * Whether all devices linked to the account support delete syncing */ bool delete_sync = 2; + /** + * Whether all devices linked to the account support a versioned expiration timer + */ + bool versioned_expiration_timer = 3; } message Badge { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java index a313d714..64bbe965 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java @@ -216,7 +216,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final AccountAttributes accountAttributes = new AccountAttributes(fetchesMessages, 1234, 5678, null, - null, true, new DeviceCapabilities(true, true, true, false)); + null, true, new DeviceCapabilities(true, true, true, false, false)); final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), accountAttributes, @@ -293,7 +293,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), - new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, deviceSupportsDeleteSync)), + new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, deviceSupportsDeleteSync, false)), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); try (final Response response = resources.getJerseyTest() @@ -314,6 +314,59 @@ class DeviceControllerTest { Arguments.of(false, false, 200)); } + @ParameterizedTest + @MethodSource + void deviceDowngradeVersionedExpirationTimer(final boolean accountSupportsVersionedExpirationTimer, + final boolean deviceSupportsVersionedExpirationTimer, final int expectedStatus) { + when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); + when(accountsManager.addDevice(any(), any())) + .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(accountSupportsVersionedExpirationTimer); + + when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); + + final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, deviceSupportsVersionedExpirationTimer, 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 deviceDowngradeVersionedExpirationTimer() { + return List.of( + Arguments.of(true, true, 200), + Arguments.of(true, false, 409), + Arguments.of(false, true, 200), + Arguments.of(false, false, 200)); + } + @Test void linkDeviceAtomicBadCredentials() { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); @@ -683,7 +736,7 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), - new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, new DeviceCapabilities(true, true, true, false)), + new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, new DeviceCapabilities(true, true, true, false, false)), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.of(new ApnRegistrationId("apn", null)), Optional.empty())); try (final Response response = resources.getJerseyTest() @@ -742,7 +795,7 @@ class DeviceControllerTest { @Test void putCapabilitiesSuccessTest() { - final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, false); + final DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, false, false); final Response response = resources .getJerseyTest() .target("/v1/devices/capabilities") diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java index f4c2548f..57ccc3e2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -59,6 +59,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.jupiter.cartesian.CartesianTest; import org.mockito.ArgumentCaptor; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.ServiceId; @@ -439,10 +440,12 @@ class ProfileControllerTest { assertThat(response.getStatus()).isEqualTo(401); } - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testProfileCapabilities(final boolean isDeleteSyncSupported) { + @CartesianTest + void testProfileCapabilities( + @CartesianTest.Values(booleans = {true, false}) final boolean isDeleteSyncSupported, + @CartesianTest.Values(booleans = {true, false}) final boolean isVersionedExpirationTimerSupported) { when(capabilitiesAccount.isDeleteSyncSupported()).thenReturn(isDeleteSyncSupported); + when(capabilitiesAccount.isVersionedExpirationTimerSupported()).thenReturn(isVersionedExpirationTimerSupported); final BaseProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID) .request() @@ -450,6 +453,7 @@ class ProfileControllerTest { .get(BaseProfileResponse.class); assertEquals(isDeleteSyncSupported, profile.getCapabilities().deleteSync()); + assertEquals(isVersionedExpirationTimerSupported, profile.getCapabilities().versionedExpirationTimer()); assertThat(profile.getCapabilities().paymentActivation()).isTrue(); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java index 9ffe908c..017670ba 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java @@ -526,10 +526,10 @@ class RegistrationControllerTest { } final AccountAttributes fetchesMessagesAccountAttributes = - new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false, false)); 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, new Device.DeviceCapabilities(false, false, false, false, false)); return Stream.of( // "Fetches messages" is true, but an APNs token is provided @@ -615,7 +615,7 @@ class RegistrationControllerTest { } final AccountAttributes accountAttributes = - new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false)); + new AccountAttributes(true, 1, 1, "test".getBytes(StandardCharsets.UTF_8), null, true, new Device.DeviceCapabilities(false, false, false, false, false)); return Stream.of( // Signed PNI EC pre-key is missing @@ -785,13 +785,13 @@ class RegistrationControllerTest { final int registrationId = 1; final int pniRegistrationId = 2; - final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(false, false, false, false); + final Device.DeviceCapabilities deviceCapabilities = new Device.DeviceCapabilities(false, false, false, false, false); 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, new Device.DeviceCapabilities(false, false, false, false, false)); 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, new Device.DeviceCapabilities(false, false, false, false, false)); final String apnsToken = "apns-token"; final String apnsVoipToken = "apns-voip-token"; @@ -906,7 +906,7 @@ class RegistrationControllerTest { final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey()); final AccountAttributes accountAttributes = new AccountAttributes(true, registrationId, pniRegistrationId, "name".getBytes(StandardCharsets.UTF_8), "reglock", - true, new Device.DeviceCapabilities(true, true, true, false)); + true, new Device.DeviceCapabilities(true, true, true, false, false)); final RegistrationRequest request = new RegistrationRequest( Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java index a5968aa3..9c12b133 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java @@ -395,7 +395,8 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing); case "isPaymentActivationSupported" -> when(updatedAccount.isPaymentActivationSupported()).thenAnswer(stubbing); case "isDeleteSyncSupported" -> when(updatedAccount.isDeleteSyncSupported()).thenAnswer(stubbing); + case "isVersionedExpirationTimerSupported" -> when(updatedAccount.isVersionedExpirationTimerSupported()).thenAnswer(stubbing); case "getRegistrationLock" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing); case "getIdentityKey" -> when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);