Require PQ keys when changing numbers or distributing key material

This commit is contained in:
Jon Chambers
2025-05-05 14:54:12 -04:00
committed by Jon Chambers
parent e43487155f
commit b400d49e77
11 changed files with 224 additions and 327 deletions

View File

@@ -17,6 +17,7 @@ import javax.annotation.Nullable;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
@@ -51,34 +52,27 @@ public record ChangeNumberRequest(
arraySchema=@Schema(description="""
A list of synchronization messages to send to companion devices to supply the private keysManager
associated with the new identity key and their new prekeys.
Exactly one message must be supplied for each enabled device other than the sending (primary) device."""))
Exactly one message must be supplied for each device other than the sending (primary) device."""))
@NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages,
@Schema(description="""
A new signed elliptic-curve prekey for each enabled device on the account, including this one.
A new signed elliptic-curve prekey for each device on the account, including this one.
Each must be accompanied by a valid signature from the new identity key in this request.""")
@NotNull @Valid Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
@NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
@Schema(description="""
A new signed post-quantum last-resort prekey for each enabled device on the account, including this one.
May be absent, in which case the last resort PQ prekeys for each device will be deleted if any had been stored.
If present, must contain one prekey per enabled device including this one.
Prekeys for devices that did not previously have any post-quantum prekeys stored will be silently dropped.
A new signed post-quantum last-resort prekey for each device on the account, including this one.
Each must be accompanied by a valid signature from the new identity key in this request.""")
@Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
@NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
@Schema(description="the new phone-number-identity registration ID for each enabled device on the account, including this one")
@NotNull Map<Byte, Integer> pniRegistrationIds) implements PhoneVerificationRequest {
@Schema(description="the new phone-number-identity registration ID for each device on the account, including this one")
@NotNull @NotEmpty Map<Byte, Integer> pniRegistrationIds) implements PhoneVerificationRequest {
public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {
List<SignedPreKey<?>> spks = new ArrayList<>();
if (devicePniSignedPrekeys != null) {
spks.addAll(devicePniSignedPrekeys.values());
}
if (devicePniPqLastResortPrekeys != null) {
spks.addAll(devicePniPqLastResortPrekeys.values());
}
return spks.isEmpty() || PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "change-number");
final List<SignedPreKey<?>> spks = new ArrayList<>(devicePniSignedPrekeys.values());
spks.addAll(devicePniPqLastResortPrekeys.values());
return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "change-number");
}
@AssertTrue

View File

@@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;
@@ -29,36 +30,36 @@ public record PhoneNumberIdentityKeyDistributionRequest(
arraySchema=@Schema(description="""
A list of synchronization messages to send to companion devices to supply the private keys
associated with the new identity key and their new prekeys.
Exactly one message must be supplied for each enabled device other than the sending (primary) device.
Exactly one message must be supplied for each device other than the sending (primary) device.
"""))
List<@NotNull @Valid IncomingMessage> deviceMessages,
@NotNull
@NotEmpty
@Valid
@Schema(description="""
A new signed elliptic-curve prekey for each enabled device on the account, including this one.
A new signed elliptic-curve prekey for each device on the account, including this one.
Each must be accompanied by a valid signature from the new identity key in this request.""")
Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
@NotNull
@NotEmpty
@Valid
@Schema(description="""
A new signed post-quantum last-resort prekey for each enabled device on the account, including this one.
May be absent, in which case the last resort PQ prekeys for each device will be deleted if any had been stored.
If present, must contain one prekey per enabled device including this one.
Prekeys for devices that did not previously have any post-quantum prekeys stored will be silently dropped.
A new signed post-quantum last-resort prekey for each device on the account, including this one.
Each must be accompanied by a valid signature from the new identity key in this request.""")
@Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
@NotNull
@NotEmpty
@Valid
@Schema(description="The new registration ID to use for the phone-number identity of each device, including this one.")
Map<Byte, Integer> pniRegistrationIds) {
public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {
List<SignedPreKey<?>> spks = new ArrayList<>(devicePniSignedPrekeys.values());
if (devicePniPqLastResortPrekeys != null) {
spks.addAll(devicePniPqLastResortPrekeys.values());
}
return spks.isEmpty() || PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "distribute-pni-keys");
}
final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>(devicePniSignedPrekeys.values());
signedPreKeys.addAll(devicePniPqLastResortPrekeys.values());
return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, signedPreKeys, userAgent, "distribute-pni-keys");
}
}

View File

@@ -62,7 +62,6 @@ import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.signal.libsignal.protocol.IdentityKey;
import org.slf4j.Logger;
@@ -641,19 +640,16 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
}
public Account changeNumber(final Account account,
final String targetNumber,
@Nullable final IdentityKey pniIdentityKey,
@Nullable final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
@Nullable final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
@Nullable final Map<Byte, Integer> pniRegistrationIds) throws InterruptedException, MismatchedDevicesException {
final String targetNumber,
final IdentityKey pniIdentityKey,
final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
final Map<Byte, Integer> pniRegistrationIds) throws InterruptedException, MismatchedDevicesException {
final UUID originalPhoneNumberIdentifier = account.getPhoneNumberIdentifier();
final UUID targetPhoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(targetNumber).join();
if (originalPhoneNumberIdentifier.equals(targetPhoneNumberIdentifier)) {
if (pniIdentityKey != null) {
throw new IllegalArgumentException("change number must supply a changed phone number; otherwise use updatePniKeys");
}
return account;
}
@@ -694,7 +690,7 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
.join();
final Collection<TransactWriteItem> keyWriteItems =
buildPniKeyWriteItems(uuid, targetPhoneNumberIdentifier, pniSignedPreKeys, pniPqLastResortPreKeys);
buildPniKeyWriteItems(targetPhoneNumberIdentifier, pniSignedPreKeys, pniPqLastResortPreKeys);
final Account numberChangedAccount = updateWithRetries(
account,
@@ -715,7 +711,7 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
public Account updatePniKeys(final Account account,
final IdentityKey pniIdentityKey,
final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
@Nullable final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
final Map<Byte, Integer> pniRegistrationIds) throws MismatchedDevicesException {
validateDevices(account, pniSignedPreKeys, pniPqLastResortPreKeys, pniRegistrationIds);
@@ -724,7 +720,7 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
final UUID pni = account.getIdentifier(IdentityType.PNI);
final Collection<TransactWriteItem> keyWriteItems =
buildPniKeyWriteItems(pni, pni, pniSignedPreKeys, pniPqLastResortPreKeys);
buildPniKeyWriteItems(pni, pniSignedPreKeys, pniPqLastResortPreKeys);
return redisDeleteAsync(account)
.thenCompose(ignored -> keysManager.deleteSingleUsePreKeys(pni))
@@ -739,41 +735,24 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
}
private Collection<TransactWriteItem> buildPniKeyWriteItems(
final UUID enabledDevicesIdentifier,
final UUID phoneNumberIdentifier,
@Nullable final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
@Nullable final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys) {
final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys) {
final List<TransactWriteItem> keyWriteItems = new ArrayList<>();
if (pniSignedPreKeys != null) {
pniSignedPreKeys.forEach((deviceId, signedPreKey) ->
keyWriteItems.add(keysManager.buildWriteItemForEcSignedPreKey(phoneNumberIdentifier, deviceId, signedPreKey)));
}
pniSignedPreKeys.forEach((deviceId, signedPreKey) ->
keyWriteItems.add(keysManager.buildWriteItemForEcSignedPreKey(phoneNumberIdentifier, deviceId, signedPreKey)));
if (pniPqLastResortPreKeys != null) {
keysManager.getPqEnabledDevices(enabledDevicesIdentifier)
.thenAccept(deviceIds -> deviceIds.stream()
.filter(pniPqLastResortPreKeys::containsKey)
.map(deviceId -> keysManager.buildWriteItemForLastResortKey(phoneNumberIdentifier,
deviceId,
pniPqLastResortPreKeys.get(deviceId)))
.forEach(keyWriteItems::add))
.join();
}
pniPqLastResortPreKeys.forEach((deviceId, lastResortKey) ->
keyWriteItems.add(keysManager.buildWriteItemForLastResortKey(phoneNumberIdentifier, deviceId, lastResortKey)));
return keyWriteItems;
}
private void setPniKeys(final Account account,
@Nullable final IdentityKey pniIdentityKey,
@Nullable final Map<Byte, Integer> pniRegistrationIds) {
if (ObjectUtils.allNull(pniIdentityKey, pniRegistrationIds)) {
return;
} else if (!ObjectUtils.allNotNull(pniIdentityKey, pniRegistrationIds)) {
throw new IllegalArgumentException("PNI identity key and registration IDs must be all null or all non-null");
}
final IdentityKey pniIdentityKey,
final Map<Byte, Integer> pniRegistrationIds) {
account.getDevices()
.forEach(device -> device.setPhoneNumberIdentityRegistrationId(pniRegistrationIds.get(device.getId())));
@@ -782,22 +761,15 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
}
private void validateDevices(final Account account,
@Nullable final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
@Nullable final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
@Nullable final Map<Byte, Integer> pniRegistrationIds) throws MismatchedDevicesException {
if (pniSignedPreKeys == null && pniRegistrationIds == null) {
return;
} else if (pniSignedPreKeys == null || pniRegistrationIds == null) {
throw new IllegalArgumentException("Signed pre-keys and registration IDs must both be null or both be non-null");
}
final Map<Byte, ECSignedPreKey> pniSignedPreKeys,
final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,
final Map<Byte, Integer> pniRegistrationIds) throws MismatchedDevicesException {
// Check that all including primary ID are in signed pre-keys
validateCompleteDeviceList(account, pniSignedPreKeys.keySet());
// Check that all including primary ID are in Pq pre-keys
if (pniPqLastResortPreKeys != null) {
validateCompleteDeviceList(account, pniPqLastResortPreKeys.keySet());
}
validateCompleteDeviceList(account, pniPqLastResortPreKeys.keySet());
// Check that all devices are accounted for in the map of new PNI registration IDs
validateCompleteDeviceList(account, pniRegistrationIds.keySet());
@@ -816,8 +788,7 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
extraDeviceIds.removeAll(accountDeviceIds);
if (!missingDeviceIds.isEmpty() || !extraDeviceIds.isEmpty()) {
throw new MismatchedDevicesException(
new MismatchedDevices(missingDeviceIds, extraDeviceIds, Collections.emptySet()));
throw new MismatchedDevicesException(new MismatchedDevices(missingDeviceIds, extraDeviceIds, Set.of()));
}
}

View File

@@ -42,13 +42,14 @@ public class ChangeNumberManager {
this.clock = clock;
}
public Account changeNumber(final Account account, final String number,
@Nullable final IdentityKey pniIdentityKey,
@Nullable final Map<Byte, ECSignedPreKey> deviceSignedPreKeys,
@Nullable final Map<Byte, KEMSignedPreKey> devicePqLastResortPreKeys,
@Nullable final List<IncomingMessage> deviceMessages,
@Nullable final Map<Byte, Integer> pniRegistrationIds,
@Nullable final String senderUserAgent)
public Account changeNumber(final Account account,
final String number,
final IdentityKey pniIdentityKey,
final Map<Byte, ECSignedPreKey> deviceSignedPreKeys,
final Map<Byte, KEMSignedPreKey> devicePqLastResortPreKeys,
final List<IncomingMessage> deviceMessages,
final Map<Byte, Integer> pniRegistrationIds,
final String senderUserAgent)
throws InterruptedException, MismatchedDevicesException, MessageTooLargeException {
if (!(ObjectUtils.allNotNull(pniIdentityKey, deviceSignedPreKeys, deviceMessages, pniRegistrationIds) ||

View File

@@ -5,7 +5,6 @@
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -114,10 +113,6 @@ public class KeysManager {
return ecSignedPreKeys.find(identifier, deviceId);
}
public CompletableFuture<List<Byte>> getPqEnabledDevices(final UUID identifier) {
return pqLastResortKeys.getDeviceIdsWithKeys(identifier).collectList().toFuture();
}
public CompletableFuture<Integer> getEcCount(final UUID identifier, final byte deviceId) {
return ecPreKeys.getCount(identifier, deviceId);
}

View File

@@ -14,14 +14,12 @@ import java.util.concurrent.CompletableFuture;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import reactor.core.publisher.Flux;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.Delete;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.Put;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
/**
@@ -116,20 +114,6 @@ public abstract class RepeatedUseSignedPreKeyStore<K extends SignedPreKey<?>> {
return findFuture;
}
public Flux<Byte> getDeviceIdsWithKeys(final UUID identifier) {
return Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()
.tableName(tableName)
.keyConditionExpression("#uuid = :uuid")
.expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID))
.expressionAttributeValues(Map.of(
":uuid", getPartitionKey(identifier)))
.projectionExpression(KEY_DEVICE_ID)
.consistentRead(true)
.build())
.items())
.map(item -> Byte.parseByte(item.get(KEY_DEVICE_ID).n()));
}
protected static Map<String, AttributeValue> getPrimaryKey(final UUID identifier, final byte deviceId) {
return Map.of(
KEY_ACCOUNT_UUID, getPartitionKey(identifier),