From 186a93f5d174cb26f3776e948481c9ff85d302e0 Mon Sep 17 00:00:00 2001 From: Clark Date: Thu, 15 Jun 2023 10:51:52 -0400 Subject: [PATCH] Use separate PNI key distribution endpoint instead of change number. --- .../changenumber/ChangeNumberRepository.kt | 31 ++-- .../app/internal/InternalSettingsFragment.kt | 3 +- .../securesms/jobs/PnpInitializeDevicesJob.kt | 134 +++++++++++++++++- .../api/SignalServiceAccountManager.java | 10 ++ .../api/SignalServiceMessageSender.java | 2 +- .../account/PniKeyDistributionRequest.java | 70 +++++++++ .../internal/push/PushServiceSocket.java | 9 ++ 7 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/PniKeyDistributionRequest.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt index 0af4d043e4..6008676c1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt @@ -113,7 +113,7 @@ class ChangeNumberRepository( .timeout(15, TimeUnit.SECONDS) } - fun changeNumber(sessionId: String? = null, recoveryPassword: String? = null, newE164: String, pniUpdateMode: Boolean = false): Single> { + fun changeNumber(sessionId: String? = null, recoveryPassword: String? = null, newE164: String): Single> { check((sessionId != null && recoveryPassword == null) || (sessionId == null && recoveryPassword != null)) return Single.fromCallable { @@ -125,8 +125,7 @@ class ChangeNumberRepository( val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest( sessionId = sessionId, recoveryPassword = recoveryPassword, - newE164 = newE164, - pniUpdateMode = pniUpdateMode + newE164 = newE164 ) SignalStore.misc().setPendingChangeNumberMetadata(metadata) @@ -308,19 +307,17 @@ class ChangeNumberRepository( }.subscribeOn(Schedulers.single()) } - @Suppress("UsePropertyAccessSyntax") @WorkerThread private fun createChangeNumberRequest( sessionId: String? = null, recoveryPassword: String? = null, newE164: String, - registrationLock: String? = null, - pniUpdateMode: Boolean = false + registrationLock: String? = null ): ChangeNumberRequestData { val selfIdentifier: String = SignalStore.account().requireAci().toString() val aciProtocolStore: SignalProtocolStore = ApplicationDependencies.getProtocolStore().aci() - val pniIdentity: IdentityKeyPair = if (pniUpdateMode) SignalStore.account().pniIdentityKey else IdentityKeyUtil.generateIdentityKeyPair() + val pniIdentity: IdentityKeyPair = IdentityKeyUtil.generateIdentityKeyPair() val deviceMessages = mutableListOf() val devicePniSignedPreKeys = mutableMapOf() val devicePniLastResortKyberPreKeys = mutableMapOf() @@ -334,11 +331,7 @@ class ChangeNumberRepository( .forEach { deviceId -> // Signed Prekeys val signedPreKeyRecord: SignedPreKeyRecord = if (deviceId == primaryDeviceId) { - if (pniUpdateMode) { - ApplicationDependencies.getProtocolStore().pni().loadSignedPreKey(SignalStore.account().pniPreKeys.activeSignedPreKeyId) - } else { - PreKeyUtil.generateAndStoreSignedPreKey(ApplicationDependencies.getProtocolStore().pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) - } + PreKeyUtil.generateAndStoreSignedPreKey(ApplicationDependencies.getProtocolStore().pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) } else { PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) } @@ -346,22 +339,14 @@ class ChangeNumberRepository( // Last-resort kyber prekeys val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) { - if (pniUpdateMode) { - ApplicationDependencies.getProtocolStore().pni().loadKyberPreKey(SignalStore.account().pniPreKeys.lastResortKyberPreKeyId) - } else { - PreKeyUtil.generateAndStoreLastResortKyberPreKey(ApplicationDependencies.getProtocolStore().pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) - } + PreKeyUtil.generateAndStoreLastResortKyberPreKey(ApplicationDependencies.getProtocolStore().pni(), SignalStore.account().pniPreKeys, pniIdentity.privateKey) } else { PreKeyUtil.generateKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) } devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature) // Registration Ids - var pniRegistrationId = if (deviceId == primaryDeviceId && pniUpdateMode) { - SignalStore.account().pniRegistrationId - } else { - -1 - } + var pniRegistrationId = -1 while (pniRegistrationId < 0 || pniRegistrationIds.values.contains(pniRegistrationId)) { pniRegistrationId = KeyHelper.generateRegistrationId(false) @@ -378,7 +363,7 @@ class ChangeNumberRepository( .setNewE164(newE164) .build() - deviceMessages += messageSender.getEncryptedSyncPniChangeNumberMessage(deviceId, pniChangeNumber) + deviceMessages += messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId, pniChangeNumber) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 1c8d808666..cfad09463d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -568,8 +568,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter sectionHeaderPref(DSLSettingsText.from("PNP")) clickPref( - title = DSLSettingsText.from("Trigger No-Op Change Number"), - summary = DSLSettingsText.from("Mimics the 'Hello world' event"), + title = DSLSettingsText.from("Trigger 'Hello World' event"), isEnabled = true, onClick = { SimpleTask.run(viewLifecycleOwner.lifecycle, { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt index dcf9a358e4..67a8493d3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt @@ -1,17 +1,41 @@ package org.thoughtcrime.securesms.jobs +import androidx.annotation.WorkerThread +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.safeBlockingGet import org.signal.core.util.logging.Log +import org.signal.core.util.orNull +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignalProtocolStore +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.protocol.util.KeyHelper +import org.signal.libsignal.protocol.util.Medium import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberRepository +import org.thoughtcrime.securesms.crypto.PreKeyUtil +import org.thoughtcrime.securesms.database.model.toProtoByteString import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.VerifyResponse import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity +import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity +import org.whispersystems.signalservice.internal.push.OutgoingPushMessage +import org.whispersystems.signalservice.internal.push.SignalServiceProtos +import org.whispersystems.signalservice.internal.push.VerifyAccountResponse +import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException import java.io.IOException +import java.security.SecureRandom /** * To be run when all clients support PNP and we need to initialize all linked devices with appropriate PNP data. @@ -23,7 +47,6 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base companion object { const val KEY = "PnpInitializeDevicesJob" private val TAG = Log.tag(PnpInitializeDevicesJob::class.java) - private const val PLACEHOLDER_SESSION_ID = "123456789" @JvmStatic fun enqueueIfNecessary() { @@ -81,13 +104,11 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base return } - val changeNumberRepository = ChangeNumberRepository() val e164 = SignalStore.account().requireE164() try { - Log.i(TAG, "Calling change number with our current number to distribute PNI messages") - changeNumberRepository - .changeNumber(sessionId = PLACEHOLDER_SESSION_ID, newE164 = e164, pniUpdateMode = true) + Log.i(TAG, "Initializing PNI for linked devices") + initializeDevices(e164) .map(::VerifyResponseWithoutKbs) .safeBlockingGet() .resultOrThrow @@ -104,6 +125,109 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base } } + private fun initializeDevices(newE164: String): Single> { + val accountManager = ApplicationDependencies.getSignalServiceAccountManager() + val messageSender = ApplicationDependencies.getSignalServiceMessageSender() + + return Single.fromCallable { + var completed = false + var attempts = 0 + lateinit var distributionResponse: ServiceResponse + + while (!completed && attempts < 5) { + val request = createInitializeDevicesRequest( + newE164 = newE164 + ) + + distributionResponse = accountManager.distributePniKeys(request) + + val possibleError: Throwable? = distributionResponse.applicationError.orNull() + if (possibleError is MismatchedDevicesException) { + messageSender.handleChangeNumberMismatchDevices(possibleError.mismatchedDevices) + attempts++ + } else { + completed = true + } + } + + VerifyResponse.from(distributionResponse, null, null) + }.subscribeOn(Schedulers.single()) + .onErrorReturn { t -> ServiceResponse.forExecutionError(t) } + } + + @WorkerThread + private fun createInitializeDevicesRequest( + newE164: String + ): PniKeyDistributionRequest { + val selfIdentifier: String = SignalStore.account().requireAci().toString() + val aciProtocolStore: SignalProtocolStore = ApplicationDependencies.getProtocolStore().aci() + val pniProtocolStore: SignalProtocolStore = ApplicationDependencies.getProtocolStore().pni() + val messageSender = ApplicationDependencies.getSignalServiceMessageSender() + + val pniIdentity: IdentityKeyPair = SignalStore.account().pniIdentityKey + val deviceMessages = mutableListOf() + val devicePniSignedPreKeys = mutableMapOf() + val devicePniLastResortKyberPreKeys = mutableMapOf() + val pniRegistrationIds = mutableMapOf() + val primaryDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID + + val devices: List = listOf(primaryDeviceId) + aciProtocolStore.getSubDeviceSessions(selfIdentifier) + + devices + .filter { it == primaryDeviceId || aciProtocolStore.containsSession(SignalProtocolAddress(selfIdentifier, it)) } + .forEach { deviceId -> + // Signed Prekeys + val signedPreKeyRecord: SignedPreKeyRecord = if (deviceId == primaryDeviceId) { + pniProtocolStore.loadSignedPreKey(SignalStore.account().pniPreKeys.activeSignedPreKeyId) + } else { + PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) + } + devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature) + + // Last-resort kyber prekeys + val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) { + pniProtocolStore.loadKyberPreKey(SignalStore.account().pniPreKeys.lastResortKyberPreKeyId) + } else { + PreKeyUtil.generateKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey) + } + devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature) + + // Registration Ids + var pniRegistrationId = if (deviceId == primaryDeviceId) { + SignalStore.account().pniRegistrationId + } else { + -1 + } + + while (pniRegistrationId < 0 || pniRegistrationIds.values.contains(pniRegistrationId)) { + pniRegistrationId = KeyHelper.generateRegistrationId(false) + } + pniRegistrationIds[deviceId] = pniRegistrationId + + // Device Messages + if (deviceId != primaryDeviceId) { + val pniChangeNumber = SignalServiceProtos.SyncMessage.PniChangeNumber.newBuilder() + .setIdentityKeyPair(pniIdentity.serialize().toProtoByteString()) + .setSignedPreKey(signedPreKeyRecord.serialize().toProtoByteString()) + .setLastResortKyberPreKey(lastResortKyberPreKeyRecord.serialize().toProtoByteString()) + .setRegistrationId(pniRegistrationId) + .setNewE164(newE164) + .build() + + deviceMessages += messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId, pniChangeNumber) + } + } + + return PniKeyDistributionRequest( + pniIdentity.publicKey, + deviceMessages, + devicePniSignedPreKeys.mapKeys { it.key.toString() }, + devicePniLastResortKyberPreKeys.mapKeys { it.key.toString() }, + pniRegistrationIds.mapKeys { it.key.toString() }, + true + ) + } + override fun onShouldRetry(e: Exception): Boolean { return e is IOException } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index b3276a1efc..0ba375b337 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -18,6 +18,7 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; +import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest; import org.whispersystems.signalservice.api.account.PreKeyUpload; import org.whispersystems.signalservice.api.crypto.ProfileCipher; import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; @@ -651,6 +652,15 @@ public class SignalServiceAccountManager { this.pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext); } + public ServiceResponse distributePniKeys(PniKeyDistributionRequest request) { + try { + VerifyAccountResponse response = this.pushServiceSocket.distributePniKeys(request); + return ServiceResponse.forResult(response, 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } + } + public List getDevices() throws IOException { return this.pushServiceSocket.getDevices(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 2c565d7747..10ea9f2cac 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -708,7 +708,7 @@ public class SignalServiceMessageSender { * @param pniChangeNumber - Linked device specific updated PNI details * @return Encrypted {@link OutgoingPushMessage} to be included in the change number request sent to the server */ - public @Nonnull OutgoingPushMessage getEncryptedSyncPniChangeNumberMessage(int deviceId, @Nonnull SyncMessage.PniChangeNumber pniChangeNumber) + public @Nonnull OutgoingPushMessage getEncryptedSyncPniInitializeDeviceMessage(int deviceId, @Nonnull SyncMessage.PniChangeNumber pniChangeNumber) throws UntrustedIdentityException, IOException, InvalidKeyException { SyncMessage.Builder syncMessage = createSyncMessageBuilder().setPniChangeNumber(pniChangeNumber); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/PniKeyDistributionRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/PniKeyDistributionRequest.java new file mode 100644 index 0000000000..ca8c266c96 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/PniKeyDistributionRequest.java @@ -0,0 +1,70 @@ +package org.whispersystems.signalservice.api.account; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.signal.libsignal.protocol.IdentityKey; +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; +import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity; +import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.util.List; +import java.util.Map; + +public final class PniKeyDistributionRequest { + @JsonProperty + @JsonSerialize(using = JsonUtil.IdentityKeySerializer.class) + @JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class) + private IdentityKey pniIdentityKey; + + @JsonProperty + private List deviceMessages; + + @JsonProperty + private Map devicePniSignedPrekeys; + + @JsonProperty("devicePniPqLastResortPrekeys") + private Map devicePniLastResortKyberPrekeys; + + @JsonProperty + private Map pniRegistrationIds; + + @JsonProperty + private boolean signatureValidOnEachSignedPreKey; + + @SuppressWarnings("unused") + public PniKeyDistributionRequest() {} + + public PniKeyDistributionRequest(IdentityKey pniIdentityKey, + List deviceMessages, + Map devicePniSignedPrekeys, + Map devicePniLastResortKyberPrekeys, + Map pniRegistrationIds, + boolean signatureValidOnEachSignedPreKey) + { + this.pniIdentityKey = pniIdentityKey; + this.deviceMessages = deviceMessages; + this.devicePniSignedPrekeys = devicePniSignedPrekeys; + this.devicePniLastResortKyberPrekeys = devicePniLastResortKyberPrekeys; + this.pniRegistrationIds = pniRegistrationIds; + this.signatureValidOnEachSignedPreKey = signatureValidOnEachSignedPreKey; + } + + public IdentityKey getPniIdentityKey() { + return pniIdentityKey; + } + + public List getDeviceMessages() { + return deviceMessages; + } + + public Map getDevicePniSignedPrekeys() { + return devicePniSignedPrekeys; + } + + public Map getPniRegistrationIds() { + return pniRegistrationIds; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 3e02008af8..b6c3929c07 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -40,6 +40,7 @@ import org.signal.storageservice.protos.groups.GroupExternalCredential; import org.signal.storageservice.protos.groups.GroupJoinInfo; import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; +import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest; import org.whispersystems.signalservice.api.account.PreKeyUpload; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; @@ -216,6 +217,7 @@ public class PushServiceSocket { private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number"; private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; private static final String REQUEST_ACCOUNT_DATA_PATH = "/v2/accounts/data_report"; + private static final String PNI_KEY_DISTRUBTION_PATH = "/v2/accounts/phone_number_identity_key_distribution"; private static final String PREKEY_METADATA_PATH = "/v2/keys?identity=%s"; private static final String PREKEY_PATH = "/v2/keys?identity=%s"; @@ -448,6 +450,13 @@ public class PushServiceSocket { return JsonUtil.fromJson(responseBody, VerifyAccountResponse.class); } + public VerifyAccountResponse distributePniKeys(@NonNull PniKeyDistributionRequest distributionRequest) throws IOException { + String request = JsonUtil.toJson(distributionRequest); + String response = makeServiceRequest(PNI_KEY_DISTRUBTION_PATH, "PUT", request); + + return JsonUtil.fromJson(response, VerifyAccountResponse.class); + } + public void setAccountAttributes(@Nonnull AccountAttributes accountAttributes) throws IOException {