diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java index 6f627be3c..24e7bf3b9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java @@ -7,7 +7,10 @@ package org.whispersystems.textsecuregcm.entities; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter; public record DeviceInfo(long id, @@ -17,9 +20,27 @@ public record DeviceInfo(long id, byte[] name, long lastSeen, - long created) { + + @Deprecated + @Schema(description = """ + The time in milliseconds since epoch when the device was linked. + Deprecated in favor of `createdAtCiphertext`. + """, deprecated = true) + long created, + + @Schema(description = "The registration ID of the given device.") + int registrationId, + + @JsonSerialize(using = ByteArrayAdapter.Serializing.class) + @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) + @Schema(description = """ + The ciphertext of the time in milliseconds since epoch when the device was attached + to the parent account, encoded in standard base64 without padding. + """) + byte[] createdAtCiphertext) { public static DeviceInfo forDevice(final Device device) { - return new DeviceInfo(device.getId(), device.getName(), device.getLastSeen(), device.getCreated()); + return new DeviceInfo(device.getId(), device.getName(), device.getLastSeen(), device.getCreated(), device.getRegistrationId( + IdentityType.ACI), device.getCreatedAtCiphertext()); } } 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 47f51b7ed..a389bc735 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java @@ -27,6 +27,7 @@ import org.signal.chat.device.SetPushTokenRequest; import org.signal.chat.device.SetPushTokenResponse; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.DeviceCapability; @@ -61,6 +62,8 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase { .setId(device.getId()) .setCreated(device.getCreated()) .setLastSeen(device.getLastSeen()) + .setRegistrationId(device.getRegistrationId(IdentityType.ACI)) + .setCreatedAtCiphertext(ByteString.copyFrom(device.getCreatedAtCiphertext())) .build()); }) .map(GetDevicesResponse.Builder::build); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java index 00ca475c0..02645b31b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java @@ -308,7 +308,7 @@ public class AccountsManager extends RedisPubSubAdapter implemen account.setNumber(number, pni); account.setIdentityKey(aciIdentityKey); account.setPhoneNumberIdentityKey(pniIdentityKey); - account.addDevice(primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock)); + account.addDevice(primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock, aciIdentityKey)); account.setRegistrationLockFromAttributes(accountAttributes); account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey()); account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess()); @@ -436,7 +436,7 @@ public class AccountsManager extends RedisPubSubAdapter implemen final Account account = accountAndNextDeviceId.first(); final byte nextDeviceId = accountAndNextDeviceId.second(); - account.addDevice(deviceSpec.toDevice(nextDeviceId, clock)); + account.addDevice(deviceSpec.toDevice(nextDeviceId, clock, account.getIdentityKey(IdentityType.ACI))); final List additionalWriteItems = new ArrayList<>(keysManager.buildWriteItemsForNewDevice( account.getIdentifier(IdentityType.ACI), 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 8193b4034..27b14c47b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java @@ -13,7 +13,6 @@ import java.time.Duration; import java.util.Collections; import java.util.EnumSet; import java.util.List; -import java.util.OptionalInt; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -21,6 +20,7 @@ import javax.annotation.Nullable; import com.google.common.annotations.VisibleForTesting; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter; import org.whispersystems.textsecuregcm.util.DeviceNameByteArrayAdapter; @@ -44,6 +44,11 @@ public class Device { @JsonDeserialize(using = DeviceNameByteArrayAdapter.Deserializer.class) private byte[] name; + @JsonProperty("createdAt") + @JsonSerialize(using = ByteArrayAdapter.Serializing.class) + @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) + private byte[] createdAtCiphertext; + @JsonProperty private String authToken; @@ -110,6 +115,14 @@ public class Device { return this.created; } + public void setCreatedAtCiphertext(byte[] createdAtCiphertext) { + this.createdAtCiphertext = createdAtCiphertext; + } + + public byte[] getCreatedAtCiphertext() { + return this.createdAtCiphertext; + } + public String getGcmId() { return gcmId; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java index 016e9bde4..b8e195d13 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java @@ -5,11 +5,13 @@ import java.util.Arrays; import java.util.Objects; import java.util.Optional; import java.util.Set; +import org.signal.libsignal.protocol.IdentityKey; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey; +import org.whispersystems.textsecuregcm.util.EncryptDeviceCreationTimestampUtil; import org.whispersystems.textsecuregcm.util.Util; public record DeviceSpec( @@ -27,7 +29,9 @@ public record DeviceSpec( KEMSignedPreKey aciPqLastResortPreKey, KEMSignedPreKey pniPqLastResortPreKey) { - public Device toDevice(final byte deviceId, final Clock clock) { + public Device toDevice(final byte deviceId, final Clock clock, final IdentityKey aciIdentityKey) { + final long created = clock.millis(); + final Device device = new Device(); device.setId(deviceId); device.setAuthTokenHash(SaltedTokenHash.generateFor(password())); @@ -36,7 +40,9 @@ public record DeviceSpec( device.setPhoneNumberIdentityRegistrationId(pniRegistrationId()); device.setName(deviceNameCiphertext()); device.setCapabilities(capabilities()); - device.setCreated(clock.millis()); + device.setCreated(created); + device.setCreatedAtCiphertext( + EncryptDeviceCreationTimestampUtil.encrypt(created, aciIdentityKey, deviceId, aciRegistrationId())); device.setLastSeen(Util.todayInMillis()); device.setUserAgent(signalAgent()); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/EncryptDeviceCreationTimestampUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/EncryptDeviceCreationTimestampUtil.java new file mode 100644 index 000000000..da91b6130 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/EncryptDeviceCreationTimestampUtil.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.google.common.annotations.VisibleForTesting; +import org.signal.libsignal.protocol.IdentityKey; +import java.nio.ByteBuffer; + +public class EncryptDeviceCreationTimestampUtil { + @VisibleForTesting + final static String ENCRYPTION_INFO = "deviceCreatedAt"; + + /** + * Encrypts the provided timestamp with the ACI identity key using the + * Hybrid Public Key Encryption scheme as defined in (RFC 9180). + * + * @param createdAt The timestamp in milliseconds since epoch when a given device was linked to the account. + * @param aciIdentityKey The ACI identity key associated with the account. + * @param deviceId The ID of the given device. + * @param registrationId The registration ID of the given device. + * + * @return The timestamp ciphertext + */ + public static byte[] encrypt( + final long createdAt, + final IdentityKey aciIdentityKey, + final byte deviceId, + final int registrationId) { + final ByteBuffer timestampBytes = ByteBuffer.allocate(8); + timestampBytes.putLong(createdAt); + + // "Associated data" is metadata that ties the ciphertext to a specific context to prevent an adversary from + // swapping the ciphertext of two devices on the same account. + final ByteBuffer associatedData = ByteBuffer.allocate(5); + associatedData.put(deviceId); + associatedData.putInt(registrationId); + + return aciIdentityKey.getPublicKey().seal(timestampBytes.array(), ENCRYPTION_INFO, associatedData.array()); + } +} diff --git a/service/src/main/proto/org/signal/chat/device.proto b/service/src/main/proto/org/signal/chat/device.proto index d0ca4affc..ac1442e77 100644 --- a/service/src/main/proto/org/signal/chat/device.proto +++ b/service/src/main/proto/org/signal/chat/device.proto @@ -10,6 +10,7 @@ option java_multiple_files = true; package org.signal.chat.device; import "org/signal/chat/common.proto"; +import "org/signal/chat/require.proto"; /** * Provides methods for working with devices attached to a Signal account. @@ -84,6 +85,18 @@ message GetDevicesResponse { * device last connected to the server. */ uint64 last_seen = 4; + + /** + * The registration ID of the given device. + */ + uint32 registration_id = 5 [(require.range).max = 0x3fff]; + + /** + * A sequence of bytes that encodes the time, + * in milliseconds since the epoch, at which this device was + * attached to its parent account. + */ + bytes created_at_ciphertext = 6; } /** 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 aac7ee44c..44c5c2a6f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java @@ -190,12 +190,16 @@ class DeviceControllerTest { final byte[] deviceName = "refreshed-device-name".getBytes(StandardCharsets.UTF_8); final long deviceCreated = System.currentTimeMillis(); final long deviceLastSeen = deviceCreated + 1; + final int registrationId = 2; + final byte[] createdAtCiphertext = "timestamp ciphertext".getBytes(StandardCharsets.UTF_8); final Device refreshedDevice = mock(Device.class); when(refreshedDevice.getId()).thenReturn(deviceId); when(refreshedDevice.getName()).thenReturn(deviceName); when(refreshedDevice.getCreated()).thenReturn(deviceCreated); when(refreshedDevice.getLastSeen()).thenReturn(deviceLastSeen); + when(refreshedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId); + when(refreshedDevice.getCreatedAtCiphertext()).thenReturn(createdAtCiphertext); final Account refreshedAccount = mock(Account.class); when(refreshedAccount.getDevices()).thenReturn(List.of(refreshedDevice)); @@ -213,6 +217,8 @@ class DeviceControllerTest { assertArrayEquals(deviceName, deviceInfoList.devices().getFirst().name()); assertEquals(deviceCreated, deviceInfoList.devices().getFirst().created()); assertEquals(deviceLastSeen, deviceInfoList.devices().getFirst().lastSeen()); + assertEquals(registrationId, deviceInfoList.devices().getFirst().registrationId()); + assertArrayEquals(createdAtCiphertext, deviceInfoList.devices().getFirst().createdAtCiphertext()); } @ParameterizedTest @@ -241,7 +247,8 @@ class DeviceControllerTest { aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair); pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair); - when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); + 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(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); @@ -250,7 +257,7 @@ class DeviceControllerTest { final Account a = invocation.getArgument(0); final DeviceSpec deviceSpec = invocation.getArgument(1); - return CompletableFuture.completedFuture(new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock))); + return CompletableFuture.completedFuture(new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey))); }); when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); @@ -273,7 +280,7 @@ class DeviceControllerTest { final ArgumentCaptor deviceSpecCaptor = ArgumentCaptor.forClass(DeviceSpec.class); verify(accountsManager).addDevice(eq(account), deviceSpecCaptor.capture(), any()); - final Device device = deviceSpecCaptor.getValue().toDevice(NEXT_DEVICE_ID, testClock); + final Device device = deviceSpecCaptor.getValue().toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey); assertEquals(fetchesMessages, device.getFetchesMessages()); @@ -741,15 +748,16 @@ class DeviceControllerTest { final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair); final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair); final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair); + final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey()); - when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); + when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> { final Account a = invocation.getArgument(0); final DeviceSpec deviceSpec = invocation.getArgument(1); - return CompletableFuture.completedFuture(new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock))); + return CompletableFuture.completedFuture(new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey))); }); when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); @@ -953,7 +961,9 @@ class DeviceControllerTest { final DeviceInfo deviceInfo = new DeviceInfo(Device.PRIMARY_ID, "Device name ciphertext".getBytes(StandardCharsets.UTF_8), System.currentTimeMillis(), - System.currentTimeMillis()); + System.currentTimeMillis(), + 1, + "timestamp ciphertext".getBytes(StandardCharsets.UTF_8)); final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]); @@ -976,6 +986,8 @@ class DeviceControllerTest { assertArrayEquals(deviceInfo.name(), retrievedDeviceInfo.name()); assertEquals(deviceInfo.created(), retrievedDeviceInfo.created()); assertEquals(deviceInfo.lastSeen(), retrievedDeviceInfo.lastSeen()); + assertEquals(deviceInfo.registrationId(), retrievedDeviceInfo.registrationId()); + assertArrayEquals(deviceInfo.createdAtCiphertext(), retrievedDeviceInfo.createdAtCiphertext()); } } 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 4acff9cc3..5bb304eb3 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java @@ -48,6 +48,7 @@ import org.signal.chat.device.SetDeviceNameRequest; import org.signal.chat.device.SetDeviceNameResponse; import org.signal.chat.device.SetPushTokenRequest; import org.signal.chat.device.SetPushTokenResponse; +import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; @@ -102,11 +103,17 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest