Introduce encrypted device creation timestamps

This commit is contained in:
Katherine
2025-07-23 10:36:11 -04:00
committed by GitHub
parent 74c7e49cea
commit 96f6e75702
13 changed files with 181 additions and 14 deletions

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -308,7 +308,7 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> 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<String, String> 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<TransactWriteItem> additionalWriteItems = new ArrayList<>(keysManager.buildWriteItemsForNewDevice(
account.getIdentifier(IdentityType.ACI),

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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 (<a href="https://www.rfc-editor.org/rfc/rfc9180.html">RFC 9180</a>).
*
* @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());
}
}