diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java index c0adaa33ab..6f52f03fa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java @@ -62,7 +62,9 @@ public class PassphraseCreateActivity extends PassphraseActivity { MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret); SignalStore.account().generateAciIdentityKeyIfNecessary(); - SignalStore.account().generatePniIdentityKeyIfNecessary(); + if (SignalStore.account().getPni() != null) { + SignalStore.account().generatePniIdentityKeyIfNecessary(); + } VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this); return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java index 6e4f30dec4..867d409443 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java @@ -42,12 +42,7 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter { Context context = getContext(); - if (SignalStore.account().getE164() == null) { - Log.i(TAG, "No local number set, skipping all sync operations."); - return; - } - - if (!SignalStore.account().isRegistered()) { + if (!SignalStore.account().isRegistered() || SignalStore.account().getAci() == null) { Log.i(TAG, "Not push registered. Just syncing contact info."); ContactDiscovery.syncRecipientInfoWithSystemContacts(context); return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt index 0e124090b7..197b68b0a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt @@ -323,7 +323,7 @@ object ContactDiscovery { val protocolAddress = Recipient.resolved(id).requireServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID) return AppDependencies.protocolStore.aci().containsSession(protocolAddress) || - AppDependencies.protocolStore.pni().containsSession(protocolAddress) + AppDependencies.protocolStore.pniOrNull()?.containsSession(protocolAddress) == true } class RefreshResult( diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java index 2bb34a9dc4..e2dbfee2dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java @@ -128,8 +128,8 @@ public class SignalBaseIdentityKeyStore { public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, IdentityKeyStore.Direction direction) { boolean isSelf = address.getName().equals(SignalStore.account().requireAci().toString()) || - address.getName().equals(SignalStore.account().requirePni().toString()) || - address.getName().equals(SignalStore.account().getE164()); + (SignalStore.account().getPni() != null && address.getName().equals(SignalStore.account().requirePni().toString())) || + (SignalStore.account().getE164() != null && address.getName().equals(SignalStore.account().getE164())); if (isSelf) { return identityKey.equals(SignalStore.account().getAciIdentityKey().getPublicKey()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalServiceDataStoreImpl.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalServiceDataStoreImpl.java index 18db539e46..55f10831d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalServiceDataStoreImpl.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalServiceDataStoreImpl.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.crypto.storage; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.whispersystems.signalservice.api.SignalServiceDataStore; @@ -16,7 +17,7 @@ public final class SignalServiceDataStoreImpl implements SignalServiceDataStore public SignalServiceDataStoreImpl(@NonNull Context context, @NonNull SignalServiceAccountDataStoreImpl aciStore, - @NonNull SignalServiceAccountDataStoreImpl pniStore) + @Nullable SignalServiceAccountDataStoreImpl pniStore) { this.context = context; this.aciStore = aciStore; @@ -28,7 +29,7 @@ public final class SignalServiceDataStoreImpl implements SignalServiceDataStore if (accountIdentifier.equals(SignalStore.account().getAci())) { return aciStore; } else if (accountIdentifier.equals(SignalStore.account().getPni())) { - return pniStore; + return pni(); } else { throw new IllegalArgumentException("No matching store found for " + accountIdentifier); } @@ -40,7 +41,15 @@ public final class SignalServiceDataStoreImpl implements SignalServiceDataStore } @Override - public SignalServiceAccountDataStoreImpl pni() { + public @NonNull SignalServiceAccountDataStoreImpl pni() { + if (pniStore == null) { + throw new IllegalStateException("No PNI store! Account has no PNI. Use pniOrNull() on paths that tolerate a phone-number-less account."); + } + return pniStore; + } + + @Override + public @Nullable SignalServiceAccountDataStoreImpl pniOrNull() { return pniStore; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/KyberPreKeyTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/KyberPreKeyTable.kt index f2fb999af5..16f3048211 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/KyberPreKeyTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/KyberPreKeyTable.kt @@ -155,6 +155,14 @@ class KyberPreKeyTable(context: Context, databaseHelper: SignalDatabase) : Datab .run() } + /** Deletes every kyber pre-key belonging to the given identity. Cascades to [LastResortKeyTupleTable]. */ + fun deleteAll(serviceId: ServiceId) { + writableDatabase + .delete(TABLE_NAME) + .where("$ACCOUNT_ID = ?", serviceId.toAccountId()) + .run() + } + fun markAllStaleIfNecessary(serviceId: ServiceId, staleTime: Long) { writableDatabase .update(TABLE_NAME) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyTable.kt index 01de4f5eb8..05134ba4e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyTable.kt @@ -78,6 +78,14 @@ class OneTimePreKeyTable(context: Context, databaseHelper: SignalDatabase) : Dat database.delete(TABLE_NAME, "$ACCOUNT_ID = ? AND $KEY_ID = ?", SqlUtil.buildArgs(serviceId.toAccountId(), keyId)) } + /** Deletes every one-time EC pre-key belonging to the given identity. */ + fun deleteAll(serviceId: ServiceId) { + writableDatabase + .delete(TABLE_NAME) + .where("$ACCOUNT_ID = ?", serviceId.toAccountId()) + .run() + } + fun markAllStaleIfNecessary(serviceId: ServiceId, staleTime: Long) { writableDatabase .update(TABLE_NAME) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 06bf9dcbdf..0b0b2f4c27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -2323,12 +2323,29 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da /** * Associates the provided IDs together. The assumption here is that all of the IDs correspond to the local user and have been verified. + * The PNI and E164 are optional, as an account may have no phone number. */ - fun linkIdsForSelf(aci: ACI, pni: PNI, e164: String) { + fun linkIdsForSelf(aci: ACI, pni: PNI?, e164: String?) { val id: RecipientId = getAndPossiblyMerge(aci = aci, pni = pni, e164 = e164, changeSelf = true, pniVerified = true) updatePendingSelfData(id) } + /** + * Wipes the E164 and PNI off of the self recipient, leaving it ACI-only. + * + * Does *not* handle clearing the recipient cache. It is assumed the caller handles this. + */ + fun clearSelfE164AndPni(selfId: RecipientId) { + val contentValues = contentValuesOf( + E164 to null, + PNI_COLUMN to null + ) + + if (update(selfId, contentValues)) { + AppDependencies.databaseObserver.notifyRecipientChanged(selfId) + } + } + /** * Does *not* handle clearing the recipient cache. It is assumed the caller handles this. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyTable.kt index 3ec2149459..1d6536c6bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyTable.kt @@ -5,6 +5,7 @@ import androidx.core.content.contentValuesOf import org.signal.core.models.ServiceId import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil +import org.signal.core.util.delete import org.signal.core.util.deleteAll import org.signal.core.util.logging.Log import org.signal.core.util.requireInt @@ -105,8 +106,16 @@ class SignedPreKeyTable(context: Context, databaseHelper: SignalDatabase) : Data writableDatabase.delete(TABLE_NAME, "$ACCOUNT_ID = ? AND $KEY_ID = ?", SqlUtil.buildArgs(serviceId.toAccountId(), keyId)) } + /** Deletes every signed pre-key belonging to the given identity. */ + fun deleteAll(serviceId: ServiceId) { + writableDatabase + .delete(TABLE_NAME) + .where("$ACCOUNT_ID = ?", serviceId.toAccountId()) + .run() + } + fun debugDeleteAll() { - writableDatabase.deleteAll(OneTimePreKeyTable.TABLE_NAME) + writableDatabase.deleteAll(TABLE_NAME) } private fun ServiceId.toAccountId(): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index a888b048f8..5a6f2b1639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -457,10 +457,6 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { throw new IllegalStateException("No ACI set!"); } - if (localPni == null) { - throw new IllegalStateException("No PNI set!"); - } - boolean needsPreKeyJob = false; if (!SignalStore.account().hasAciIdentityKey()) { @@ -468,7 +464,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { needsPreKeyJob = true; } - if (!SignalStore.account().hasPniIdentityKey()) { + if (localPni != null && !SignalStore.account().hasPniIdentityKey()) { SignalStore.account().generatePniIdentityKeyIfNecessary(); needsPreKeyJob = true; } @@ -486,12 +482,16 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { new TextSecureSessionStore(localAci), new SignalSenderKeyStore(context)); - SignalServiceAccountDataStoreImpl pniStore = new SignalServiceAccountDataStoreImpl(context, - new TextSecurePreKeyStore(localPni), - new SignalKyberPreKeyStore(localPni), - new SignalIdentityKeyStore(baseIdentityStore, () -> SignalStore.account().getPniIdentityKey()), - new TextSecureSessionStore(localPni), - new SignalSenderKeyStore(context)); + SignalServiceAccountDataStoreImpl pniStore = null; + if (localPni != null) { + pniStore = new SignalServiceAccountDataStoreImpl(context, + new TextSecurePreKeyStore(localPni), + new SignalKyberPreKeyStore(localPni), + new SignalIdentityKeyStore(baseIdentityStore, () -> SignalStore.account().getPniIdentityKey()), + new TextSecureSessionStore(localPni), + new SignalSenderKeyStore(context)); + } + return new SignalServiceDataStoreImpl(context, aciStore, pniStore); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt index 6a273cb7a2..83c6374f60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt @@ -73,6 +73,12 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) : return } + if (SignalStore.account.pni == null) { + Log.i(TAG, "Phonenumberless account, skipping the rest.") + SignalStore.misc.lastConsistencyCheckTime = System.currentTimeMillis() + return + } + val pniProfile: SignalServiceProfile = ProfileUtil.retrieveProfileSync(SignalStore.account.pni!!, SignalServiceProfile.RequestType.PROFILE).profile val encodedPniPublicKey = Base64.encodeWithPadding(SignalStore.account.pniIdentityKey.publicKey.serialize()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.kt index c5ae78037c..15b5c8b14f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.kt @@ -171,7 +171,7 @@ class IndividualSendJob private constructor(parameters: Parameters, private val val recipientId = message.threadRecipient.id AppDependencies.protocolStore.aci().sessions().archiveSessions(recipientId) - AppDependencies.protocolStore.pni().sessions().archiveSessions(recipientId) + AppDependencies.protocolStore.pniOrNull()?.sessions()?.archiveSessions(recipientId) throw RetryLaterException() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2.kt index 94d77b420b..9c2daf68e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2.kt @@ -389,8 +389,9 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va ) } else { val pniSignature = if (recipient.needsPniSignature) { - Log.i(TAG, "${logPrefix(dataMessage.timestamp)} Including PNI signature.") - AppDependencies.signalServiceMessageSender.createPniSignatureMessage() + AppDependencies.signalServiceMessageSender.createPniSignatureMessage()?.also { + Log.i(TAG, "${logPrefix(dataMessage.timestamp)} Including PNI signature.") + } } else { null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt index 89a0a0d482..26fe06d747 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt @@ -120,7 +120,7 @@ class PreKeysSyncJob private constructor( } override fun onRun() { - if (!SignalStore.account.isRegistered || SignalStore.account.aci == null || SignalStore.account.pni == null) { + if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) { warn(TAG, "Not yet registered") return } @@ -138,7 +138,7 @@ class PreKeysSyncJob private constructor( if (!checkPreKeyConsistency(ServiceIdType.ACI, AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)) { warn(TAG, ServiceIdType.ACI, "Prekey consistency check failed! Must rotate keys!") true - } else if (!checkPreKeyConsistency(ServiceIdType.PNI, AppDependencies.protocolStore.pni(), SignalStore.account.pniPreKeys)) { + } else if (AppDependencies.protocolStore.pniOrNull()?.let { !checkPreKeyConsistency(ServiceIdType.PNI, it, SignalStore.account.pniPreKeys) } == true) { warn(TAG, ServiceIdType.PNI, "Prekey consistency check failed! Must rotate keys! (ACI consistency check must have passed)") true } else { @@ -160,7 +160,13 @@ class PreKeysSyncJob private constructor( } syncPreKeys(ServiceIdType.ACI, SignalStore.account.aci, AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys, forceRotation) - syncPreKeys(ServiceIdType.PNI, SignalStore.account.pni, AppDependencies.protocolStore.pni(), SignalStore.account.pniPreKeys, forcePniRotation) + + val pniProtocolStore = AppDependencies.protocolStore.pniOrNull() + if (pniProtocolStore != null) { + syncPreKeys(ServiceIdType.PNI, SignalStore.account.pni, pniProtocolStore, SignalStore.account.pniPreKeys, forcePniRotation) + } else { + warn(TAG, ServiceIdType.PNI, "No PNI store. Skipping PNI prekey sync.") + } SignalStore.misc.lastFullPrekeyRefreshTime = System.currentTimeMillis() if (forcePniRotation) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReceiptSender.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReceiptSender.kt index 01e4a87e57..aa86717f22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReceiptSender.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReceiptSender.kt @@ -28,7 +28,7 @@ object ReceiptSender { Log.w(TAG, "Failed to send receipt due to a missing session. Archiving sessions and retrying.", e) AppDependencies.protocolStore.aci().sessions().archiveSessions(recipientId) - AppDependencies.protocolStore.pni().sessions().archiveSessions(recipientId) + AppDependencies.protocolStore.pniOrNull()?.sessions()?.archiveSessions(recipientId) try { operation.send() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index 69b6af9113..1603d53c82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -86,7 +86,7 @@ public class RefreshAttributesJob extends BaseJob { @Override public void onRun() throws IOException { - if (!SignalStore.account().isRegistered() || SignalStore.account().getE164() == null) { + if (!SignalStore.account().isRegistered() || SignalStore.account().getAci() == null) { Log.w(TAG, "Not yet registered. Skipping."); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt index 870254c0a0..b56ed86b24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt @@ -81,7 +81,7 @@ class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob @Throws(Exception::class) override fun onRun() { - if (!SignalStore.account.isRegistered || SignalStore.account.e164.isNullOrEmpty()) { + if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) { Log.w(TAG, "Not yet registered!") return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java index 8445dd2213..5972382ba6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java @@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.NoSessionException; import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage; import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil; +import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl; import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListRecord; @@ -192,8 +193,11 @@ public class ResendMessageJob extends BaseJob { Log.w(TAG, "Failed to resend content due to a missing session. Archiving session and trying again.", e); AppDependencies.getProtocolStore().aci().sessions().archiveSessions(recipientId, SignalServiceAddress.DEFAULT_DEVICE_ID); AppDependencies.getProtocolStore().aci().sessions().archiveSiblingSessions(recipient.getServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID)); - AppDependencies.getProtocolStore().pni().sessions().archiveSessions(recipientId, SignalServiceAddress.DEFAULT_DEVICE_ID); - AppDependencies.getProtocolStore().pni().sessions().archiveSiblingSessions(recipient.getServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID)); + SignalServiceAccountDataStoreImpl pniStore = AppDependencies.getProtocolStore().pniOrNull(); + if (pniStore != null) { + pniStore.sessions().archiveSessions(recipientId, SignalServiceAddress.DEFAULT_DEVICE_ID); + pniStore.sessions().archiveSiblingSessions(recipient.getServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID)); + } SignalDatabase.senderKeyShared().deleteAllFor(recipientId); result = messageSender.resendContent(address, access, sentTimestamp, contentToSend, contentHint, Optional.ofNullable(groupId).map(GroupId::getDecodedId), urgent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt index 984bb82ee4..831d389d2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt @@ -61,7 +61,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob( return } - if (!SignalStore.account.isRegistered || SignalStore.account.e164 == null) { + if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) { Log.w(TAG, "User not registered. Skipping.") return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt index fdb4b1d277..e611c9b664 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt @@ -40,7 +40,7 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job return Result.failure() } - if (!SignalStore.account.isRegistered || SignalStore.account.e164 == null) { + if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) { Log.w(TAG, "User not registered. Skipping.") return Result.failure() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index df1a2922be..30293df29b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -192,8 +192,8 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc return } - if (!Recipient.self().hasE164 || !Recipient.self().hasServiceId) { - Log.w(TAG, "Missing E164 or ACI!") + if (!Recipient.self().hasAci) { + Log.w(TAG, "Missing ACI!") return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 8998dfff90..7e9a95c7f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -248,6 +248,26 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) putString(KEY_E164, e164) } + /** Wipes all local knowledge of the user's E164 and PNI, including the PNI identity and pre-key metadata. */ + fun clearE164AndPni() { + store + .beginWrite() + .remove(KEY_E164) + .remove(KEY_PNI) + .remove(KEY_PNI_IDENTITY_PUBLIC_KEY) + .remove(KEY_PNI_IDENTITY_PRIVATE_KEY) + .remove(KEY_PNI_REGISTRATION_ID) + .remove(KEY_PNI_SIGNED_PREKEY_REGISTERED) + .remove(KEY_PNI_NEXT_SIGNED_PREKEY_ID) + .remove(KEY_PNI_ACTIVE_SIGNED_PREKEY_ID) + .remove(KEY_PNI_LAST_SIGNED_PREKEY_ROTATION_TIME) + .remove(KEY_PNI_NEXT_ONE_TIME_PREKEY_ID) + .remove(KEY_PNI_NEXT_KYBER_PREKEY_ID) + .remove(KEY_PNI_LAST_RESORT_KYBER_PREKEY_ID) + .remove(KEY_PNI_LAST_RESORT_KYBER_PREKEY_ROTATION_TIME) + .commit() + } + /** The password for communicating with the Signal service. */ val servicePassword: String? get() = getString(KEY_SERVICE_PASSWORD, null) @@ -271,7 +291,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) ) } - /** The identity key pair for the PNI identity. */ + /** The identity key pair for the PNI identity. Will throw if not present -- prefer [pniIdentityKeyOrNull] on paths that tolerate a phone-number-less account. */ val pniIdentityKey: IdentityKeyPair get() { require(store.containsKey(KEY_PNI_IDENTITY_PUBLIC_KEY)) { "Not yet set!" } @@ -281,6 +301,10 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) ) } + /** The identity key pair for the PNI identity, or null if the account has no PNI identity. */ + val pniIdentityKeyOrNull: IdentityKeyPair? + get() = if (hasPniIdentityKey()) pniIdentityKey else null + fun hasAciIdentityKey(): Boolean { return store.containsKey(KEY_ACI_IDENTITY_PUBLIC_KEY) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index e5ad8ccd3d..2a5dda113b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -235,13 +235,13 @@ object LinkDeviceRepository { } val deviceLinkResult = SignalNetwork.linkDevice.linkDevice( - e164 = SignalStore.account.e164!!, + e164 = SignalStore.account.e164, aci = SignalStore.account.aci!!, - pni = SignalStore.account.pni!!, + pni = SignalStore.account.pni, deviceIdentifier = ephemeralId, deviceKey = publicKey, aciIdentityKeyPair = SignalStore.account.aciIdentityKey, - pniIdentityKeyPair = SignalStore.account.pniIdentityKey, + pniIdentityKeyPair = SignalStore.account.pniIdentityKeyOrNull, profileKey = ProfileKeyUtil.getSelfProfileKey(), accountEntropyPool = SignalStore.account.accountEntropyPool, masterKey = SignalStore.svr.masterKey, diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java index f74ea4a81b..c2392f2315 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java @@ -26,7 +26,7 @@ final class LogSectionBadges implements LogSection { return "Unregistered"; } - if (SignalStore.account().getE164() == null || SignalStore.account().getAci() == null) { + if (SignalStore.account().getAci() == null) { return "Self not yet available!"; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java index acfcea4d0d..7bee2a3c0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java @@ -25,7 +25,7 @@ public final class LogSectionCapabilities implements LogSection { return "Unregistered"; } - if (SignalStore.account().getE164() == null || SignalStore.account().getAci() == null) { + if (SignalStore.account().getAci() == null) { return "Self not yet available!"; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt index c54ba097a2..8e5b0f97ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt @@ -408,7 +408,7 @@ class IncomingMessageObserver( * Comparing the batch-start PNI against the current value makes the check idempotent — a * redelivered envelope finds the PNI already applied and won't re-trigger a websocket reset. */ - private fun isNetworkResetRequired(result: MessageDecryptor.Result.Success, pniAtBatchStart: ServiceId.PNI): Boolean { + private fun isNetworkResetRequired(result: MessageDecryptor.Result.Success, pniAtBatchStart: ServiceId.PNI?): Boolean { return result.content.syncMessage?.pniChangeNumber != null && SignalStore.account.pni != pniAtBatchStart } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptor.kt index 01f84d1d50..8d0ba437f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptor.kt @@ -102,7 +102,7 @@ object MessageDecryptor { serverDeliveredTimestamp: Long ): Result { val selfAci: ACI = SignalStore.account.requireAci() - val selfPni: PNI = SignalStore.account.requirePni() + val selfPni: PNI? = SignalStore.account.pni val destination: ServiceId? = ServiceId.parseOrNull(envelope.destinationServiceId, envelope.destinationServiceIdBinary) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/protocol/BufferedProtocolStore.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/protocol/BufferedProtocolStore.kt index a4e60d8fc3..9b9a02b1e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/protocol/BufferedProtocolStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/protocol/BufferedProtocolStore.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.messages.protocol import org.signal.core.models.ServiceId import org.signal.core.models.ServiceId.PNI +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -14,16 +15,16 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore */ class BufferedProtocolStore private constructor( private val aciStore: Pair, - private val pniStore: Pair + private val pniStore: Pair? ) { - /** The PNI captured when this batch's store was created. Does not refresh if [SignalStore.account.pni] later changes mid-batch. */ - val pni: PNI get() = pniStore.first + /** The PNI captured when this batch's store was created, or null if the account had no PNI. Does not refresh if [SignalStore.account.pni] later changes mid-batch. */ + val pni: PNI? get() = pniStore?.first fun get(serviceId: ServiceId): BufferedSignalServiceAccountDataStore { - return when (serviceId) { - aciStore.first -> aciStore.second - pniStore.first -> pniStore.second + return when { + serviceId == aciStore.first -> aciStore.second + pniStore != null && serviceId == pniStore.first -> pniStore.second else -> error("No store matching serviceId $serviceId") } } @@ -37,17 +38,27 @@ class BufferedProtocolStore private constructor( */ fun flushToDisk() { aciStore.second.flushToDisk(AppDependencies.protocolStore.aci()) - pniStore.second.flushToDisk(AppDependencies.protocolStore.pni()) + + if (pniStore != null) { + val diskPniStore = AppDependencies.protocolStore.pniOrNull() + if (diskPniStore != null) { + pniStore.second.flushToDisk(diskPniStore) + } else { + Log.w(TAG, "Have buffered PNI data, but the account no longer has a PNI store! Discarding it.") + } + } } companion object { + private val TAG = Log.tag(BufferedProtocolStore::class) + fun create(): BufferedProtocolStore { val aci = SignalStore.account.requireAci() - val pni = SignalStore.account.requirePni() + val pni = SignalStore.account.pni return BufferedProtocolStore( aciStore = aci to BufferedSignalServiceAccountDataStore(aci), - pniStore = pni to BufferedSignalServiceAccountDataStore(pni) + pniStore = pni?.let { it to BufferedSignalServiceAccountDataStore(it) } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java index 622938939c..e0cd70b9dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java @@ -31,9 +31,10 @@ public class EditProxyViewModel extends ViewModel { this.events = PublishSubject.create(); this.uiState = BehaviorSubject.create(); this.saveState = BehaviorSubject.createDefault(SaveState.IDLE); - this.pipeState = SignalStore.account().getE164() == null ? Flowable.empty() - : AppDependencies.getWebSocketObserver() - .toFlowable(BackpressureStrategy.LATEST); + this.pipeState = !SignalStore.account().isRegistered() || SignalStore.account().getAci() == null + ? Flowable.empty() + : AppDependencies.getWebSocketObserver() + .toFlowable(BackpressureStrategy.LATEST); if (SignalStore.proxy().isProxyEnabled()) { uiState.onNext(UiState.ALL_ENABLED); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 6258ce0380..50bad8ad11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.withContext import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.core.models.ServiceId.ACI -import org.signal.core.models.ServiceId.PNI import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult import org.signal.libsignal.protocol.IdentityKey @@ -647,17 +646,14 @@ class AppRegistrationNetworkController( if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { val msg = result.message val aci = msg.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(msg.aci) - val pni = msg.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(msg.pni) trySend( LinkDeviceProvisioningEvent.MessageReceived( LinkDeviceProvisioningMessage( - e164 = msg.number!!, provisioningCode = msg.provisioningCode!!, aci = aci.toString(), - pni = pni.toString(), aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())), - pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())), + phoneNumberData = LinkDeviceProvisioningMessage.PhoneNumberData.fromProvisionMessage(msg), profileKey = msg.profileKey!!.toByteArray(), ephemeralBackupKey = msg.ephemeralBackupKey, accountEntropyPool = msg.accountEntropyPool, @@ -705,16 +701,16 @@ class AppRegistrationNetworkController( } override suspend fun registerAsLinkedDevice( - e164: String, + aci: ACI, password: String, provisioningCode: String, deviceAttributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult { return registrationApi.registerAsSecondaryDevice( - e164 = e164, + aci = aci, password = password, verificationCode = provisioningCode, attributes = deviceAttributes, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index 5afcef510d..87e53b770c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -39,6 +39,7 @@ import org.signal.core.util.StreamUtil import org.signal.core.util.crypto.AttachmentSecretProvider import org.signal.core.util.getLength import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfBlank import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord @@ -97,6 +98,7 @@ import org.thoughtcrime.securesms.service.DirectoryRefreshListener import org.thoughtcrime.securesms.service.LocalBackupListener import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener import org.thoughtcrime.securesms.util.BackupUtil +import org.thoughtcrime.securesms.util.Environment import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.link.TransferArchiveResponse import java.io.File @@ -281,6 +283,26 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo RegistrationUtil.maybeMarkRegistrationComplete() } + override suspend fun onRegistrationFlowFinished() = withContext(Dispatchers.Default) { + if (Environment.MOCK_PHONE_NUMBERLESS_REGISTRATION) { + Log.w(TAG, "[onRegistrationFlowFinished] Mocking a phone-number-less account. Wiping all local knowledge of the E164 and PNI.") + + val pni = SignalStore.account.pni + + SignalStore.account.clearE164AndPni() + SignalDatabase.recipients.clearSelfE164AndPni(Recipient.self().id) + AppDependencies.recipientCache.clearSelf() + + if (pni != null) { + SignalDatabase.oneTimePreKeys.deleteAll(pni) + SignalDatabase.signedPreKeys.deleteAll(pni) + SignalDatabase.kyberPreKeys.deleteAll(pni) + } + + AppDependencies.resetProtocolStores() + } + } + override suspend fun setRestoreDecision(decision: RestoreDecision) = withContext(Dispatchers.Default) { if (!SignalStore.registration.restoreDecisionState.isDecisionPending) { return@withContext @@ -698,8 +720,9 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo return accountEntropyPool.deriveMasterKey() } + /** The E164 and PNI are deliberately not required -- an account may have no phone number. */ private fun AccountData.isComplete(): Boolean { - return e164.isNotEmpty() && aci.isNotEmpty() && pni.isNotEmpty() && servicePassword.isNotEmpty() + return aci.isNotEmpty() && servicePassword.isNotEmpty() } /** @@ -720,45 +743,61 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo } val aciIdentityKeyPair = IdentityKeyPair(accountData.aciIdentityKeyPair.toByteArray()) - val pniIdentityKeyPair = IdentityKeyPair(accountData.pniIdentityKeyPair.toByteArray()) SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize()) - SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize()) + + val pniIdentityKeyPair = accountData.pniIdentityKeyPair.takeIf { it.size > 0 }?.let { IdentityKeyPair(it.toByteArray()) } + if (pniIdentityKeyPair != null) { + SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize()) + } val aci = ACI.parseOrThrow(accountData.aci) - val pni = PNI.parseOrThrow(accountData.pni) + val pni = PNI.parseOrNull(accountData.pni) + val e164 = accountData.e164.nullIfBlank() val isAciChanged = SignalStore.account.aci != aci + if (pni == null) { + Log.i(TAG, "[applyAccountData] No PNI in the account data. Registering an account with no phone number.") + } + SignalStore.account.setAci(aci) - SignalStore.account.setPni(pni) + if (pni != null) { + SignalStore.account.setPni(pni) + } AppDependencies.resetProtocolStores() - AppDependencies.protocolStore.aci().sessions().archiveAllSessions() - AppDependencies.protocolStore.pni().sessions().archiveAllSessions() + val aciProtocolStore = AppDependencies.protocolStore.aci() + val pniProtocolStore = AppDependencies.protocolStore.pniOrNull() + + aciProtocolStore.sessions().archiveAllSessions() + pniProtocolStore?.sessions()?.archiveAllSessions() SenderKeyUtil.clearAllState() - val aciProtocolStore = AppDependencies.protocolStore.aci() - val pniProtocolStore = AppDependencies.protocolStore.pni() - storeSignedAndLastResortPreKeys(aciProtocolStore, SignalStore.account.aciPreKeys, SignedPreKeyRecord(accountData.aciSignedPreKey.toByteArray()), KyberPreKeyRecord(accountData.aciLastResortKyberPreKey.toByteArray())) - storeSignedAndLastResortPreKeys(pniProtocolStore, SignalStore.account.pniPreKeys, SignedPreKeyRecord(accountData.pniSignedPreKey.toByteArray()), KyberPreKeyRecord(accountData.pniLastResortKyberPreKey.toByteArray())) + if (pniProtocolStore != null) { + storeSignedAndLastResortPreKeys(pniProtocolStore, SignalStore.account.pniPreKeys, SignedPreKeyRecord(accountData.pniSignedPreKey.toByteArray()), KyberPreKeyRecord(accountData.pniLastResortKyberPreKey.toByteArray())) + } - val profileKey = getOrCreateProfileKey(accountData.e164) + val profileKey = getOrCreateProfileKey(aci) val recipientTable = SignalDatabase.recipients - val selfId = recipientTable.getAndPossiblyMergePnpVerified(aci, pni, accountData.e164) + val selfId = recipientTable.getAndPossiblyMergePnpVerified(aci, pni, e164) recipientTable.setProfileSharing(selfId, true) recipientTable.markRegisteredOrThrow(selfId, aci) - recipientTable.linkIdsForSelf(aci, pni, accountData.e164) + recipientTable.linkIdsForSelf(aci, pni, e164) recipientTable.setProfileKey(selfId, profileKey) AppDependencies.recipientCache.clearSelf() - SignalStore.account.setE164(accountData.e164) + if (e164 != null) { + SignalStore.account.setE164(e164) + } val now = System.currentTimeMillis() saveOwnIdentityKey(selfId, aci, aciProtocolStore, now) - saveOwnIdentityKey(selfId, pni, pniProtocolStore, now) + if (pni != null && pniProtocolStore != null) { + saveOwnIdentityKey(selfId, pni, pniProtocolStore, now) + } accountData.linkedDeviceData?.mediaRootBackupKey?.let { SignalStore.backup.mediaRootBackupKey = MediaRootBackupKey(it.toByteArray()) @@ -810,8 +849,8 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo accountData.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) } } - private fun getOrCreateProfileKey(e164: String): ProfileKey { - val existing = SignalDatabase.recipients.getByE164(e164).getOrNull()?.let { ProfileKeyUtil.profileKeyOrNull(SignalDatabase.recipients.getRecord(it).profileKey) } + private fun getOrCreateProfileKey(aci: ACI?): ProfileKey { + val existing = aci?.let { SignalDatabase.recipients.getByAci(it).getOrNull() }?.let { ProfileKeyUtil.profileKeyOrNull(SignalDatabase.recipients.getRecord(it).profileKey) } return existing ?: ProfileKeyUtil.createNew().also { Log.i(TAG, "[commitRegistrationData] No profile key found, created a new one") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index dae9bb4d54..5d9473d38e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -41,6 +41,7 @@ import org.signal.storageservice.storage.protos.groups.ExternalGroupCredential; import org.thoughtcrime.securesms.calls.quality.CallQuality; import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil; +import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl; import org.thoughtcrime.securesms.database.CallLinkTable; import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.GroupTable; @@ -1347,7 +1348,11 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. private void archiveSessions(@NonNull RecipientId recipientId) { AppDependencies.getProtocolStore().aci().sessions().archiveSessions(recipientId); - AppDependencies.getProtocolStore().pni().sessions().archiveSessions(recipientId); + + SignalServiceAccountDataStoreImpl pniStore = AppDependencies.getProtocolStore().pniOrNull(); + if (pniStore != null) { + pniStore.sessions().archiveSessions(recipientId); + } } public void sendAcceptedCallEventSyncMessage(@NonNull RemotePeer remotePeer, boolean isOutgoing, boolean isVideoCall) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 9644bbc60f..8bbbdfffc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -30,6 +30,12 @@ object Environment { const val PHONENUMBERLESS_REGISTRATION: Boolean = false + /** + * When true, all local knowledge of the user's phone number (E164 and PNI) is wiped once registration completes, + * mimicking the experience of an account that has no phone number. Flip manually for local testing. + */ + const val MOCK_PHONE_NUMBERLESS_REGISTRATION: Boolean = false + object Backups { @JvmStatic fun supportsGooglePlayBilling(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.kt index b302ea521b..df63c960dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.kt @@ -82,7 +82,7 @@ object SignalProxyUtil { fun testWebsocketConnection(timeout: Long): Boolean { startListeningToWebsocket() - if (SignalStore.account.e164 == null) { + if (!SignalStore.account.isRegistered || SignalStore.account.aci == null) { Log.i(TAG, "User is unregistered! Doing simple check.") return testWebsocketConnectionUnregistered(timeout) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 799fff469a..e7313d2cda 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.dependencies import androidx.media3.exoplayer.ExoPlayer +import io.mockk.every import io.mockk.mockk import okhttp3.OkHttpClient import org.signal.core.util.billing.BillingApi @@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.PendingRetryReceiptCache import org.thoughtcrime.securesms.jobmanager.JobManager +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.megaphone.MegaphoneRepository import org.thoughtcrime.securesms.messages.IncomingMessageObserver import org.thoughtcrime.securesms.notifications.MessageNotifier @@ -206,7 +208,9 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { } override fun provideProtocolStore(): SignalServiceDataStoreImpl { - return mockk(relaxed = true) + return mockk(relaxed = true) { + every { pniOrNull() } answers { if (SignalStore.account.pni != null) pni() else null } + } } override fun provideGiphyMp4Cache(): GiphyMp4Cache { diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJobTest.kt index 1768972341..b87389b57c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJobTest.kt @@ -82,6 +82,7 @@ class PreKeysSyncJobTest { // MockAppDependenciesRule; configure the chained calls we care about. every { AppDependencies.protocolStore.aci() } returns aciProtocolStore every { AppDependencies.protocolStore.pni() } returns pniProtocolStore + every { AppDependencies.protocolStore.pniOrNull() } returns pniProtocolStore val identityKeyPair = IdentityKeyPair.generate() every { aciProtocolStore.identityKeyPair } returns identityKeyPair diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageControllerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageControllerTest.kt index 699325088f..721d1d00cb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageControllerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageControllerTest.kt @@ -24,6 +24,7 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.runBlocking +import okio.ByteString import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -214,6 +215,43 @@ class AppRegistrationStorageControllerTest { verify(exactly = 0) { AppDependencies.jobManager.add(ofType()) } } + @Test + fun `commit - linked device with no phone number - applies aci-only account`() = runBlocking { + seedInProgressData( + RegistrationData( + accountData = accountData( + linkedDeviceData = LinkedDeviceData(deviceId = 2, deviceName = "device-name") + ).newBuilder() + .e164("") + .pni("") + .pniIdentityKeyPair(ByteString.EMPTY) + .pniSignedPreKey(ByteString.EMPTY) + .pniLastResortKyberPreKey(ByteString.EMPTY) + .pniRegistrationId(0) + .build(), + accountEntropyPool = aep.value + ) + ) + + controller.commitRegistrationData() + + assertThat(SignalStore.account.aci).isEqualTo(aci) + assertThat(SignalStore.account.pni).isNull() + assertThat(SignalStore.account.e164).isNull() + assertThat(SignalStore.account.hasPniIdentityKey()).isFalse() + assertThat(SignalStore.account.isRegistered).isTrue() + + assertThat(SignalStore.account.aciPreKeys.isSignedPreKeyRegistered).isTrue() + assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isFalse() + + val selfRecord = SignalDatabase.recipients.getRecord(SignalDatabase.recipients.getByAci(aci).get()) + assertThat(selfRecord.e164).isNull() + assertThat(selfRecord.pni).isNull() + assertThat(selfRecord.registered).isEqualTo(RecipientTable.RegisteredState.REGISTERED) + + assertThat(readInProgressData().accountDataCommitted).isTrue() + } + @Test fun `commit - pin opted out - applies svr opt out`() = runBlocking { seedInProgressData( diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt index a5bb235fdf..757a2cdb7c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt @@ -22,4 +22,9 @@ class EnvironmentTest { fun `phone-numberless registration is disabled`() { assertThat(Environment.PHONENUMBERLESS_REGISTRATION).isFalse() } + + @Test + fun `MOCK_PHONE_NUMBERLESS_REGISTRATION is disabled`() { + assertThat(Environment.MOCK_PHONE_NUMBERLESS_REGISTRATION).isFalse() + } } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index ee083fd128..baf04af2a4 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -8,6 +8,7 @@ package org.signal.registration.sample.debug import kotlinx.coroutines.flow.Flow import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult import org.signal.network.api.RegistrationApiV2.AccountAttributes @@ -255,15 +256,15 @@ class DebugNetworkController( } override suspend fun registerAsLinkedDevice( - e164: String, + aci: ACI, password: String, provisioningCode: String, deviceAttributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult { - return delegate.registerAsLinkedDevice(e164, password, provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, fcmToken) + return delegate.registerAsLinkedDevice(aci, password, provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, fcmToken) } override suspend fun onLinkedDeviceRegistered() { diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index aa3f61fe7c..2aff4fb9e7 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -269,17 +269,14 @@ class DemoNetworkController( if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { val msg = result.message val aci = msg.aciBinary?.let { ServiceId.ACI.parseOrThrow(it) } ?: ServiceId.ACI.parseOrThrow(msg.aci) - val pni = msg.pniBinary?.let { ServiceId.PNI.parseOrThrow(it) } ?: ServiceId.PNI.parseOrThrow(msg.pni) trySend( NetworkController.LinkDeviceProvisioningEvent.MessageReceived( NetworkController.LinkDeviceProvisioningMessage( - e164 = msg.number!!, provisioningCode = msg.provisioningCode!!, aci = aci.toString(), - pni = pni.toString(), aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())), - pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())), + phoneNumberData = NetworkController.LinkDeviceProvisioningMessage.PhoneNumberData.fromProvisionMessage(msg), profileKey = msg.profileKey!!.toByteArray(), ephemeralBackupKey = msg.ephemeralBackupKey, accountEntropyPool = msg.accountEntropyPool, @@ -327,16 +324,16 @@ class DemoNetworkController( } override suspend fun registerAsLinkedDevice( - e164: String, + aci: ServiceId.ACI, password: String, provisioningCode: String, deviceAttributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult { return registrationApi.registerAsSecondaryDevice( - e164 = e164, + aci = aci, password = password, verificationCode = provisioningCode, attributes = deviceAttributes, @@ -1298,7 +1295,8 @@ class DemoNetworkController( return ServiceDeviceAttributes( fetchesMessages = fetchesMessages, registrationId = registrationId, - pniRegistrationId = pniRegistrationId, + // The legacy service model has no way to express an absent PNI registration ID. + pniRegistrationId = pniRegistrationId ?: 0, name = name, capabilities = capabilities?.toServiceCapabilities() ) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index 544d19fb5e..319f99f853 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -232,6 +232,8 @@ class DemoStorageController(private val context: Context) : StorageController { Unit } + override suspend fun onRegistrationFlowFinished() = Unit + override suspend fun setRestoreDecision(decision: RestoreDecision) = withContext(Dispatchers.IO) { Log.i(TAG, "[setRestoreDecision] Recording restore decision: $decision") RegistrationPreferences.restoreDecision = decision diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 4271d7345f..99cb2be950 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -10,9 +10,14 @@ import kotlinx.serialization.Serializable import okio.ByteString import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI +import org.signal.core.util.logging.Log import org.signal.libsignal.net.BadRequestError import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.network.api.RegistrationApiV2.AccountAttributes import org.signal.network.api.RegistrationApiV2.CheckSvrCredentialsError import org.signal.network.api.RegistrationApiV2.CheckSvrCredentialsResponse @@ -32,6 +37,7 @@ import org.signal.network.api.RegistrationApiV2.SubmitVerificationCodeError import org.signal.network.api.RegistrationApiV2.SvrCredentials import org.signal.network.api.RegistrationApiV2.UpdateSessionError import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport +import org.whispersystems.signalservice.internal.push.ProvisionMessage import java.util.Locale import kotlin.time.Duration @@ -269,19 +275,21 @@ interface NetworkController { /** * Performs the network call to register this device as a linked (secondary) device on a pre-existing - * account (`PUT /v1/devices/link`), authenticated via basic auth with [e164] and [password]. + * account (`PUT /v1/devices/link`), authenticated via basic auth with [password] and [aci]. * * This only performs the network request and returns the assigned device id. The caller is responsible * for committing the account locally (via [StorageController.commitRegistrationData]) and performing the * post-registration housekeeping (via [onLinkedDeviceRegistered]) and any restores. + * + * @param pniPreKeys The PNI pre-keys, or null if the account has no PNI. */ suspend fun registerAsLinkedDevice( - e164: String, + aci: ACI, password: String, provisioningCode: String, deviceAttributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult @@ -476,22 +484,61 @@ interface NetworkController { /** * Data received from the primary device during QR-based device linking. * - * The ACI/PNI are resolved to their canonical string form by the implementation. Identity keys are + * The ACI is resolved to its canonical string form by the implementation. Identity keys are * provided by the primary so this device shares the account's identity. */ class LinkDeviceProvisioningMessage( - val e164: String, val provisioningCode: String, val aci: String, - val pni: String, val aciIdentityKeyPair: IdentityKeyPair, - val pniIdentityKeyPair: IdentityKeyPair, + val phoneNumberData: PhoneNumberData?, val profileKey: ByteArray, val ephemeralBackupKey: ByteString?, val accountEntropyPool: String?, val mediaRootBackupKey: ByteString?, val readReceipts: Boolean? - ) + ) { + /** + * The phone-number-linked half of the provisioning data. Absent when the account has no phone number. + * + * This is deliberately all-or-nothing: the primary either sends the E164, PNI, and PNI identity key together + * or we ignore the lot, since a partial set can't be used to register the PNI identity. + */ + class PhoneNumberData( + val e164: String, + val pni: String, + val pniIdentityKeyPair: IdentityKeyPair + ) { + companion object { + private val TAG = Log.tag(PhoneNumberData::class) + + /** + * Reads the phone-number-linked fields out of a provisioning message, or returns null if the primary didn't send + * a complete set. A primary on an account with no phone number omits all of it. + * + * Note that [ProvisionMessage.pni] is deprecated in favor of [ProvisionMessage.pniBinary], so neither is + * required on its own. + */ + fun fromProvisionMessage(message: ProvisionMessage): PhoneNumberData? { + val e164 = message.number + val pni = message.pniBinary?.let { PNI.parseOrNull(it) } ?: PNI.parseOrNull(message.pni) + val pniIdentityKeyPublic = message.pniIdentityKeyPublic + val pniIdentityKeyPrivate = message.pniIdentityKeyPrivate + + if (e164 == null || pni == null || pniIdentityKeyPublic == null || pniIdentityKeyPrivate == null) { + Log.i(TAG, "[fromProvisionMessage] No usable phone number data. hasNumber: ${e164 != null}, hasPni: ${pni != null}, hasPniIdentityKey: ${pniIdentityKeyPublic != null && pniIdentityKeyPrivate != null}. Ignoring all of it.") + return null + } + + return PhoneNumberData( + e164 = e164, + pni = pni.toString(), + pniIdentityKeyPair = IdentityKeyPair(IdentityKey(pniIdentityKeyPublic.toByteArray()), ECPrivateKey(pniIdentityKeyPrivate.toByteArray())) + ) + } + } + } + } /** * Events emitted during a device-linking provisioning session. diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt index 2d0802e322..6bb2ef1b86 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt @@ -14,7 +14,8 @@ import org.signal.registration.util.SensitiveLog * * @param isPhoneNumberlessRegistrationAvailable Whether the in-progress phone-numberless registration flow (registering * with a purchased Signal Login instead of a phone number) is offered. The screens are always present in the - * navigation graph; this only gates them off at runtime while the flow is unfinished. + * navigation graph; this only gates them off at runtime while the flow is unfinished. Also gates linking to an + * account that has no phone number. * @param sensitiveLogger A logger for logging sensitive material. The intention is this would only be used in the demo app for testing + debugging, while * the actual app would just pass null. * @param debugLogCallback Callback to launch the debug log viewer. The actual app provides the real implementation. diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index f23887df6c..d62e7f1614 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -369,20 +369,26 @@ class RegistrationRepository( ): RequestResult = withContext(Dispatchers.IO) { checkNotNull(message.accountEntropyPool) { "Link provisioning message missing account entropy pool" } - val e164 = message.e164 + val phoneNumberData = message.phoneNumberData + if (phoneNumberData == null) { + check(isPhoneNumberlessRegistrationAvailable) { "Primary sent no phone number data, but linking to an account with no phone number isn't supported!" } + Log.i(TAG, "[registerAsLinkedDevice] Primary sent no phone number data. Linking to an account with no phone number.") + } + + val e164 = phoneNumberData?.e164 val accountEntropyPool = AccountEntropyPool(message.accountEntropyPool) val aci = ACI.parseOrThrow(message.aci) - val pni = PNI.parseOrThrow(message.pni) + val pni = phoneNumberData?.let { PNI.parseOrThrow(it.pni) } val aciIdentityKeyPair = message.aciIdentityKeyPair - val pniIdentityKeyPair = message.pniIdentityKeyPair val profileKey = ProfileKey(message.profileKey) val provisioningCode = message.provisioningCode val keyMaterial = generateKeyMaterial( existingAccountEntropyPool = accountEntropyPool, existingAciIdentityKeyPair = aciIdentityKeyPair, - existingPniIdentityKeyPair = pniIdentityKeyPair, - profileKey = profileKey + existingPniIdentityKeyPair = phoneNumberData?.pniIdentityKeyPair, + profileKey = profileKey, + includePniKeyMaterial = phoneNumberData != null ) storageController.updateInProgressRegistrationData { @@ -391,15 +397,18 @@ class RegistrationRepository( } updateAccountData { this.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair.serialize().toByteString() - this.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair.serialize().toByteString() this.aciSignedPreKey = keyMaterial.aciSignedPreKey.serialize().toByteString() - this.pniSignedPreKey = keyMaterial.pniSignedPreKey.serialize().toByteString() this.aciLastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey.serialize().toByteString() - this.pniLastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey.serialize().toByteString() this.aciRegistrationId = keyMaterial.aciRegistrationId - this.pniRegistrationId = keyMaterial.pniRegistrationId this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString() this.servicePassword = keyMaterial.servicePassword + + keyMaterial.pni?.let { pniKeyMaterial -> + this.pniIdentityKeyPair = pniKeyMaterial.identityKeyPair.serialize().toByteString() + this.pniSignedPreKey = pniKeyMaterial.signedPreKey.serialize().toByteString() + this.pniLastResortKyberPreKey = pniKeyMaterial.lastResortKyberPreKey.serialize().toByteString() + this.pniRegistrationId = pniKeyMaterial.registrationId + } } val fcmToken = networkController.getFcmToken() @@ -413,7 +422,7 @@ class RegistrationRepository( val deviceAttributes = DeviceAttributes( fetchesMessages = fcmToken == null, registrationId = keyMaterial.aciRegistrationId, - pniRegistrationId = keyMaterial.pniRegistrationId, + pniRegistrationId = keyMaterial.pni?.registrationId, name = Base64.encodeWithPadding(encryptedDeviceName), capabilities = getAccountCapabilities() ) @@ -424,14 +433,16 @@ class RegistrationRepository( lastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey ) - val pniPreKeys = PreKeyCollection( - identityKey = keyMaterial.pniIdentityKeyPair.publicKey, - signedPreKey = keyMaterial.pniSignedPreKey, - lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey - ) + val pniPreKeys = keyMaterial.pni?.let { + PreKeyCollection( + identityKey = it.identityKeyPair.publicKey, + signedPreKey = it.signedPreKey, + lastResortKyberPreKey = it.lastResortKyberPreKey + ) + } val result = networkController.registerAsLinkedDevice( - e164 = e164, + aci = aci, password = keyMaterial.servicePassword, provisioningCode = provisioningCode, deviceAttributes = deviceAttributes, @@ -442,9 +453,16 @@ class RegistrationRepository( if (result is RequestResult.Success) { updateAccountData { - this.e164 = e164 this.aci = aci.toString() - this.pni = pni.toString() + + if (e164 != null) { + this.e164 = e164 + } + + if (pni != null) { + this.pni = pni.toString() + } + this.linkedDeviceData = LinkedDeviceData( deviceId = result.result.deviceId.toInt(), deviceName = deviceName, @@ -613,19 +631,21 @@ class RegistrationRepository( profileKey = resumedProfileKey ) + val pniKeyMaterial = checkNotNull(keyMaterial.pni) { "Missing PNI key material for a primary registration!" } + storageController.updateInProgressRegistrationData { this.profileKey = keyMaterial.profileKey.toByteString() this.accountEntropyPool = keyMaterial.accountEntropyPool.value } updateAccountData { this.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair.serialize().toByteString() - this.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair.serialize().toByteString() + this.pniIdentityKeyPair = pniKeyMaterial.identityKeyPair.serialize().toByteString() this.aciSignedPreKey = keyMaterial.aciSignedPreKey.serialize().toByteString() - this.pniSignedPreKey = keyMaterial.pniSignedPreKey.serialize().toByteString() + this.pniSignedPreKey = pniKeyMaterial.signedPreKey.serialize().toByteString() this.aciLastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey.serialize().toByteString() - this.pniLastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey.serialize().toByteString() + this.pniLastResortKyberPreKey = pniKeyMaterial.lastResortKyberPreKey.serialize().toByteString() this.aciRegistrationId = keyMaterial.aciRegistrationId - this.pniRegistrationId = keyMaterial.pniRegistrationId + this.pniRegistrationId = pniKeyMaterial.registrationId this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString() this.servicePassword = keyMaterial.servicePassword } @@ -652,7 +672,7 @@ class RegistrationRepository( unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess, discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later capabilities = getAccountCapabilities(), - pniRegistrationId = keyMaterial.pniRegistrationId, + pniRegistrationId = pniKeyMaterial.registrationId, recoveryPassword = newRecoveryPassword ) @@ -663,9 +683,9 @@ class RegistrationRepository( ) val pniPreKeys = PreKeyCollection( - identityKey = keyMaterial.pniIdentityKeyPair.publicKey, - signedPreKey = keyMaterial.pniSignedPreKey, - lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey + identityKey = pniKeyMaterial.identityKeyPair.publicKey, + signedPreKey = pniKeyMaterial.signedPreKey, + lastResortKyberPreKey = pniKeyMaterial.lastResortKyberPreKey ) val result = networkController.registerAccount( @@ -956,6 +976,7 @@ class RegistrationRepository( storageController.commitRegistrationData() networkController.enqueueAccountAttributesSyncJob() networkController.enqueueSvrGuessResetJobIfPossible() + storageController.onRegistrationFlowFinished() } /** @@ -968,22 +989,37 @@ class RegistrationRepository( } } + /** + * @param includePniKeyMaterial Whether to generate PNI key material at all. False for an account with no phone number, + * which has no PNI to attach the keys to. + */ private fun generateKeyMaterial( existingAccountEntropyPool: AccountEntropyPool? = null, existingAciIdentityKeyPair: IdentityKeyPair? = null, existingPniIdentityKeyPair: IdentityKeyPair? = null, - profileKey: ProfileKey? = null + profileKey: ProfileKey? = null, + includePniKeyMaterial: Boolean = true ): KeyMaterial { val accountEntropyPool = existingAccountEntropyPool ?: AccountEntropyPool.generate() val aciIdentityKeyPair = existingAciIdentityKeyPair ?: IdentityKeyPair.generate() - val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate() val timestamp = System.currentTimeMillis() val aciSignedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair) - val pniSignedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair) val aciLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair) - val pniLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair) + + val pniKeyMaterial = if (includePniKeyMaterial) { + val pniIdentityKeyPair = existingPniIdentityKeyPair ?: IdentityKeyPair.generate() + + KeyMaterial.PniKeyMaterial( + identityKeyPair = pniIdentityKeyPair, + signedPreKey = generateSignedPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair), + lastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair), + registrationId = generateRegistrationId() + ) + } else { + null + } val profileKey = profileKey ?: generateProfileKey() @@ -991,11 +1027,8 @@ class RegistrationRepository( aciIdentityKeyPair = aciIdentityKeyPair, aciSignedPreKey = aciSignedPreKey, aciLastResortKyberPreKey = aciLastResortKyberPreKey, - pniIdentityKeyPair = pniIdentityKeyPair, - pniSignedPreKey = pniSignedPreKey, - pniLastResortKyberPreKey = pniLastResortKyberPreKey, + pni = pniKeyMaterial, aciRegistrationId = generateRegistrationId(), - pniRegistrationId = generateRegistrationId(), profileKey = profileKey.serialize(), unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey), servicePassword = generatePassword(), diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 769c847160..2adc80aa8d 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -98,6 +98,12 @@ interface StorageController { */ suspend fun commitRegistrationData() + /** + * Called exactly once, after the user has finished the entire registration flow and all data has been committed. + * Gives the app a chance to do any final post-registration bookkeeping. + */ + suspend fun onRegistrationFlowFinished() + /** * Persists the terminal [RestoreDecision] the user reached during registration directly to permanent app state, * so the rest of the app knows whether we're a fresh account, skipped a restore, or successfully restored data. @@ -226,16 +232,10 @@ data class KeyMaterial( val aciSignedPreKey: SignedPreKeyRecord, /** Last resort Kyber pre-key for ACI. */ val aciLastResortKyberPreKey: KyberPreKeyRecord, - /** Identity key pair for the Phone Number Identity (PNI). */ - val pniIdentityKeyPair: IdentityKeyPair, - /** Signed pre-key for PNI. */ - val pniSignedPreKey: SignedPreKeyRecord, - /** Last resort Kyber pre-key for PNI. */ - val pniLastResortKyberPreKey: KyberPreKeyRecord, + /** Key material for the Phone Number Identity (PNI), or null for an account with no phone number. */ + val pni: PniKeyMaterial?, /** Registration ID for the ACI. */ val aciRegistrationId: Int, - /** Registration ID for the PNI. */ - val pniRegistrationId: Int, /** Profile key for sealed sender. */ val profileKey: ByteArray, /** Unidentified access key (derived from profile key) for sealed sender. */ @@ -244,7 +244,26 @@ data class KeyMaterial( val servicePassword: String, /** Account entropy pool for key derivation. */ val accountEntropyPool: AccountEntropyPool -) : Parcelable +) : Parcelable { + + /** + * The PNI half of the account's key material. Generated as a unit, and only when the account has a phone number. + */ + @Parcelize + @TypeParceler + @TypeParceler + @TypeParceler + data class PniKeyMaterial( + /** Identity key pair for the Phone Number Identity (PNI). */ + val identityKeyPair: IdentityKeyPair, + /** Signed pre-key for PNI. */ + val signedPreKey: SignedPreKeyRecord, + /** Last resort Kyber pre-key for PNI. */ + val lastResortKyberPreKey: KyberPreKeyRecord, + /** Registration ID for the PNI. */ + val registrationId: Int + ) : Parcelable +} data class NewRegistrationData( val e164: String, diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt index 0550fad441..aa61c0cc94 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI import org.signal.libsignal.net.RequestResult import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.network.api.RegistrationApiV2.AccountAttributes @@ -355,12 +356,12 @@ class FakeNetworkController( override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow = notExpected() override suspend fun registerAsLinkedDevice( - e164: String, + aci: ACI, password: String, provisioningCode: String, deviceAttributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult = notExpected() diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt index 53c24eb029..6493fba6e0 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt @@ -102,6 +102,8 @@ class FakeStorageController : StorageController { } } + override suspend fun onRegistrationFlowFinished() = Unit + override suspend fun setRestoreDecision(decision: RestoreDecision) { // Mirrors the real controller: only the first decision sticks, later ones are ignored if (restoreDecision == null) { diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceDataStore.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceDataStore.java index d9254a4d1f..2143f18658 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceDataStore.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceDataStore.java @@ -2,6 +2,8 @@ package org.whispersystems.signalservice.api; import org.signal.core.models.ServiceId; +import javax.annotation.Nullable; + /** * And extension of the normal protocol store interface that has additional methods that are needed * in the service layer, but not the protocol layer. @@ -19,10 +21,16 @@ public interface SignalServiceDataStore { SignalServiceAccountDataStore aci(); /** - * @return A {@link SignalServiceAccountDataStore} for the PNI account. + * @return A {@link SignalServiceAccountDataStore} for the PNI account. Throws if the account has no PNI, so only use this on paths that require a phone + * number. Otherwise, use {@link #pniOrNull()}. */ SignalServiceAccountDataStore pni(); + /** + * @return A {@link SignalServiceAccountDataStore} for the PNI account, or null if the account has no PNI. + */ + @Nullable SignalServiceAccountDataStore pniOrNull(); + /** * @return True if the user has linked devices, otherwise false. */ diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 43e74f9a70..9d467225c1 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -181,7 +181,7 @@ public class SignalServiceMessageSender { private final int localDeviceId; private final PNI localPni; private final Optional eventListener; - private final IdentityKeyPair localPniIdentity; + private final @Nullable IdentityKeyPair localPniIdentity; private final MessageApi messageApi; private final KeysApi keysApi; @@ -217,13 +217,18 @@ public class SignalServiceMessageSender { this.eventListener = eventListener; this.maxEnvelopeSize = maxEnvelopeSize; this.maxIncrementalMacsPerEnvelope = maxIncrementalMacsPerEnvelope; - this.localPniIdentity = store.pni().getIdentityKeyPair(); + this.localPniIdentity = localPniIdentity(store); this.scheduler = Schedulers.from(executor, false, false); this.keysApi = keysApi; this.preKeyRepository = preKeyRepository; this.useRestFallback = useRestFallback; } + private static @Nullable IdentityKeyPair localPniIdentity(SignalServiceDataStore store) { + SignalServiceAccountDataStore pniStore = store.pniOrNull(); + return pniStore != null ? pniStore.getIdentityKeyPair() : null; + } + /** * Send a read receipt for a received message. * @@ -906,7 +911,12 @@ public class SignalServiceMessageSender { return sendMessage(address, sealedSenderAccess, System.currentTimeMillis(), envelopeContent, false, null, null, false, false); } - public PniSignatureMessage createPniSignatureMessage() { + public @Nullable PniSignatureMessage createPniSignatureMessage() { + if (localPni == null || localPniIdentity == null) { + Log.w(TAG, "Tried to create a PNI signature message, but we have no PNI! Skipping."); + return null; + } + byte[] signature = localPniIdentity.signAlternateIdentity(aciStore.getIdentityKeyPair().getPublicKey()); return new PniSignatureMessage.Builder() diff --git a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt index be42cf3f7d..a4040fff93 100644 --- a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt @@ -13,6 +13,7 @@ import org.signal.core.models.ServiceId.PNI import org.signal.core.models.backup.MediaRootBackupKey import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log import org.signal.core.util.urlEncode import org.signal.libsignal.net.AuthDevicesService import org.signal.libsignal.net.RequestResult @@ -45,6 +46,11 @@ import org.signal.libsignal.net.LinkedDevice as LibSignalLinkedDevice class LinkDeviceApi( private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket ) { + + companion object { + private val TAG = Log.tag(LinkDeviceApi::class) + } + /** * Fetches a list of linked devices. */ @@ -89,15 +95,18 @@ class LinkDeviceApi( * - 411: Account is already at the device limit. * - 422: Bad request. * - 429: Rate-limited. + * + * [e164], [pni], and [pniIdentityKeyPair] are absent for an account with no phone number, and are only ever sent + * as a complete set. */ fun linkDevice( - e164: String, + e164: String?, aci: ACI, - pni: PNI, + pni: PNI?, deviceIdentifier: String, deviceKey: ECPublicKey, aciIdentityKeyPair: IdentityKeyPair, - pniIdentityKeyPair: IdentityKeyPair, + pniIdentityKeyPair: IdentityKeyPair?, profileKey: ProfileKey, accountEntropyPool: AccountEntropyPool, masterKey: MasterKey, @@ -105,15 +114,24 @@ class LinkDeviceApi( code: String, ephemeralMessageBackupKey: MessageBackupKey? ): NetworkResult { + val sendPhoneNumberData = e164 != null && pni != null && pniIdentityKeyPair != null + if (!sendPhoneNumberData && (e164 != null || pni != null || pniIdentityKeyPair != null)) { + Log.w(TAG, "[linkDevice] Incomplete phone number data! hasNumber: ${e164 != null}, hasPni: ${pni != null}, hasPniIdentityKey: ${pniIdentityKeyPair != null}. Linking the new device without a phone number.") + } + + val sentE164 = e164.takeIf { sendPhoneNumberData } + val sentPni = pni.takeIf { sendPhoneNumberData } + val sentPniIdentityKeyPair = pniIdentityKeyPair.takeIf { sendPhoneNumberData } + val cipher = PrimaryProvisioningCipher(deviceKey) val message = ProvisionMessage( aciIdentityKeyPublic = aciIdentityKeyPair.publicKey.serialize().toByteString(), aciIdentityKeyPrivate = aciIdentityKeyPair.privateKey.serialize().toByteString(), - pniIdentityKeyPublic = pniIdentityKeyPair.publicKey.serialize().toByteString(), - pniIdentityKeyPrivate = pniIdentityKeyPair.privateKey.serialize().toByteString(), + pniIdentityKeyPublic = sentPniIdentityKeyPair?.publicKey?.serialize()?.toByteString(), + pniIdentityKeyPrivate = sentPniIdentityKeyPair?.privateKey?.serialize()?.toByteString(), aci = aci.toString(), - pni = pni.toStringWithoutPrefix(), - number = e164, + pni = sentPni?.toStringWithoutPrefix(), + number = sentE164, provisioningCode = code, userAgent = null, profileKey = profileKey.serialize().toByteString(), @@ -122,7 +140,7 @@ class LinkDeviceApi( accountEntropyPool = accountEntropyPool.value, mediaRootBackupKey = mediaRootBackupKey.value.toByteString(), aciBinary = aci.toByteString(), - pniBinary = pni.toByteStringWithoutPrefix() + pniBinary = sentPni?.toByteStringWithoutPrefix() ) val ciphertext: ByteArray = cipher.encrypt(message) val body = ProvisioningMessage(Base64.encodeWithPadding(ciphertext)) diff --git a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt index 4bdb16346f..87ca5d919e 100644 --- a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt +++ b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt @@ -9,10 +9,12 @@ import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import okhttp3.Credentials import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import org.signal.core.models.ServiceId.ACI import org.signal.core.util.Base64 import org.signal.core.util.censor import org.signal.core.util.serialization.ByteArrayToBase64Serializer @@ -46,6 +48,10 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { companion object { private val APPLICATION_JSON = "application/json".toMediaType() + + /** Drops null properties instead of emitting explicit nulls, for bodies where a field is meant to be absent entirely. */ + @OptIn(ExperimentalSerializationApi::class) + private val JSON_OMITTING_NULLS = Json(SignalJson.json) { explicitNulls = false } } /** @@ -388,7 +394,7 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { /** * Registers a device as a linked device on a pre-existing account, authenticating via basic auth - * built from the given [e164] and [password]. + * built from [password] and the [aci]. * * `PUT /v1/devices/link` * - 200: Success, body is the link device response @@ -397,23 +403,25 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { * - 411: Account reached max number of linked devices * - 422: Request is invalid * - 429: Rate limited + * + * @param pniPreKeys The PNI pre-keys, or null if the account has no PNI. Omitted from the request when null. */ suspend fun registerAsSecondaryDevice( - e164: String, + aci: ACI, password: String, verificationCode: String, attributes: DeviceAttributes, aciPreKeys: PreKeyCollection, - pniPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection?, fcmToken: String? ): RequestResult { val request = RegisterAsSecondaryDeviceRequestBody( verificationCode = verificationCode, accountAttributes = attributes, aciSignedPreKey = aciPreKeys.signedPreKey.toSignedPreKeyEntity(), - pniSignedPreKey = pniPreKeys.signedPreKey.toSignedPreKeyEntity(), + pniSignedPreKey = pniPreKeys?.signedPreKey?.toSignedPreKeyEntity(), aciPqLastResortPreKey = aciPreKeys.lastResortKyberPreKey.toKyberPreKeyEntity(), - pniPqLastResortPreKey = pniPreKeys.lastResortKyberPreKey.toKyberPreKeyEntity(), + pniPqLastResortPreKey = pniPreKeys?.lastResortKyberPreKey?.toKyberPreKeyEntity(), gcmToken = fcmToken?.let { GcmRegistrationId(it, true) } ) @@ -422,8 +430,8 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { method = RequestSpec.Method.PUT, host = RequestSpec.Host.Service, path = "/v1/devices/link", - body = request.toJsonRequestBody(), - auth = RequestSpec.Auth.Header("Authorization", basicAuth(e164, password)) + body = request.toJsonRequestBodyOmittingNulls(), + auth = RequestSpec.Auth.Header("Authorization", basicAuth(aci.toString(), password)) ) ) @@ -454,6 +462,10 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { return SignalJson.json.encodeToString(this).toRequestBody(APPLICATION_JSON) } + private inline fun T.toJsonRequestBodyOmittingNulls(): RequestBody { + return JSON_OMITTING_NULLS.encodeToString(this).toRequestBody(APPLICATION_JSON) + } + private fun SignedPreKeyRecord.toSignedPreKeyEntity(): SignedPreKeyEntity { return SignedPreKeyEntity(id.toLong(), keyPair.publicKey, signature) } @@ -510,7 +522,8 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { class DeviceAttributes( val fetchesMessages: Boolean, val registrationId: Int, - val pniRegistrationId: Int, + /** Null for an account with no PNI, in which case it is omitted from the request. */ + val pniRegistrationId: Int?, val name: String?, val capabilities: AccountAttributes.Capabilities? ) @@ -679,9 +692,9 @@ class RegistrationApiV2(private val restClient: SignalRestClient) { val verificationCode: String, val accountAttributes: DeviceAttributes, val aciSignedPreKey: SignedPreKeyEntity, - val pniSignedPreKey: SignedPreKeyEntity, + val pniSignedPreKey: SignedPreKeyEntity? = null, val aciPqLastResortPreKey: KyberPreKeyEntity, - val pniPqLastResortPreKey: KyberPreKeyEntity, + val pniPqLastResortPreKey: KyberPreKeyEntity? = null, val gcmToken: GcmRegistrationId? = null )