diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 6dec2c421..0335bbe5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -39,12 +39,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return TextSecurePreferences.getLocalNumber(context) } - override fun getUserKeyPair(): Pair? { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return null - val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize() - return Pair(userPublicKey, userPrivateKey) - } - override fun getUserX25519KeyPair(): ECKeyPair { return DatabaseFactory.getLokiAPIDatabase(context).getUserX25519KeyPair() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 239b42c64..554f1958c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -2,37 +2,126 @@ package org.thoughtcrime.securesms.preferences import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import kotlinx.android.synthetic.main.dialog_clear_all_data.* import kotlinx.android.synthetic.main.dialog_clear_all_data.view.* +import kotlinx.coroutines.* import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog class ClearAllDataDialog : BaseDialog() { - override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_clear_all_data, null) - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.clearAllDataButton.setOnClickListener { clearAllData() } - builder.setView(contentView) + enum class Steps { + INFO_PROMPT, + NETWORK_PROMPT, + DELETING } - private fun clearAllData() { - if (KeyPairUtilities.hasV2KeyPair(requireContext())) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) - ApplicationContext.getInstance(context).clearAllData(false) - } else { - val dialog = AlertDialog.Builder(requireContext()) - val message = "We’ve upgraded the way Session IDs are generated, so you will be unable to restore your current Session ID." - dialog.setMessage(message) - dialog.setPositiveButton("Yes") { _, _ -> + var clearJob: Job? = null + set(value) { + field = value + } + + var step = Steps.INFO_PROMPT + set(value) { + field = value + updateUI() + } + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_clear_all_data, null) + contentView.cancelButton.setOnClickListener { + if (step == Steps.NETWORK_PROMPT) { + clearAllData(false) + } else if (step != Steps.DELETING) { + dismiss() + } + } + contentView.clearAllDataButton.setOnClickListener { + when(step) { + Steps.INFO_PROMPT -> step = Steps.NETWORK_PROMPT + Steps.NETWORK_PROMPT -> { + clearAllData(true) + } + Steps.DELETING -> { /* do nothing intentionally */ } + } + } + builder.setView(contentView) + builder.setCancelable(false) + } + + private fun updateUI() { + + dialog?.let { view -> + + val isLoading = step == Steps.DELETING + + when (step) { + Steps.INFO_PROMPT -> { + view.dialogDescriptionText.setText(R.string.dialog_clear_all_data_explanation) + view.cancelButton.setText(R.string.cancel) + view.clearAllDataButton.setText(R.string.delete) + } + else -> { + view.dialogDescriptionText.setText(R.string.dialog_clear_all_data_network_explanation) + view.cancelButton.setText(R.string.dialog_clear_all_data_local_only) + view.clearAllDataButton.setText(R.string.dialog_clear_all_data_clear_network) + } + } + + view.cancelButton.isVisible = !isLoading + view.clearAllDataButton.isVisible = !isLoading + view.progressBar.isVisible = isLoading + + view.setCanceledOnTouchOutside(!isLoading) + isCancelable = !isLoading + + } + } + + private fun clearAllData(deleteNetworkMessages: Boolean) { + clearJob = lifecycleScope.launch(Dispatchers.IO) { + val previousStep = step + withContext(Dispatchers.Main) { + step = Steps.DELETING + } + + if (!deleteNetworkMessages) { + try { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() + } catch (e: Exception) { + Log.e("Loki", "Failed to force sync", e) + } ApplicationContext.getInstance(context).clearAllData(false) + withContext(Dispatchers.Main) { + dismiss() + } + } else { + // finish + val result = try { + SnodeAPI.deleteAllMessages(requireContext()).get() + } catch (e: Exception) { + null + } + + if (result == null || result.values.any { !it } || result.isEmpty()) { + // didn't succeed (at least one) + withContext(Dispatchers.Main) { + step = previousStep + } + } else if (result.values.all { it }) { + // don't force sync because all the messages are deleted? + ApplicationContext.getInstance(context).clearAllData(false) + withContext(Dispatchers.Main) { + dismiss() + } + } } - dialog.setNegativeButton("Cancel") { _, _ -> - // Do nothing - } - dialog.create().show() } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 5bd91655b..e8c367473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -303,6 +303,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private fun clearAllData() { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } + // endregion private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index 354fa31f7..0031bcc1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.Context +import nl.komponents.kovenant.Promise import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.sending_receiving.MessageSender @@ -25,16 +26,17 @@ object ConfigurationMessageUtilities { TextSecurePreferences.setLastConfigurationSyncTime(context, now) } - fun forceSyncConfigurationNowIfNeeded(context: Context) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isGroupRecipient && !recipient.isBlocked && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> ConfigurationMessage.Contact(recipient.address.serialize(), recipient.name!!, recipient.profileAvatar, recipient.profileKey) } - val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return - MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) + val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) + val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) + return promise } } \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_clear_all_data.xml b/app/src/main/res/layout/dialog_clear_all_data.xml index 9d82d0bba..da3954ccb 100644 --- a/app/src/main/res/layout/dialog_clear_all_data.xml +++ b/app/src/main/res/layout/dialog_clear_all_data.xml @@ -1,8 +1,9 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f11b6e3f..6897c3dd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -755,6 +755,7 @@ Invite Recovery Phrase Clear Data + Clear Data Including Network Help us Translate Session Notifications @@ -777,6 +778,9 @@ Clear All Data This will permanently delete your messages, sessions, and contacts. + Would you like to clear only this device, or delete your entire account? + Delete Only + Entire Account QR Code View My QR Code diff --git a/app/src/main/res/xml/network_security_configuration.xml b/app/src/main/res/xml/network_security_configuration.xml index e0a3502bc..4e2facfd6 100644 --- a/app/src/main/res/xml/network_security_configuration.xml +++ b/app/src/main/res/xml/network_security_configuration.xml @@ -3,7 +3,7 @@ 127.0.0.1 - + public.loki.foundation diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 9e3ac258b..137a49f73 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -28,7 +28,6 @@ interface StorageProtocol { // General fun getUserPublicKey(): String? - fun getUserKeyPair(): Pair? fun getUserX25519KeyPair(): ECKeyPair fun getUserDisplayName(): String? fun getUserProfileKey(): ByteArray? diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt index 158b61e7e..701d9d4bb 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt @@ -172,9 +172,9 @@ object OpenGroupAPIV2 { } fun requestNewAuthToken(room: String, server: String): Promise { - val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserKeyPair() + val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() } ?: return Promise.ofFail(Error.Generic) - val queryParameters = mutableMapOf( "public_key" to publicKey ) + val queryParameters = mutableMapOf( "public_key" to publicKey.toHexString() ) val request = Request(GET, room, server, "auth_token_challenge", queryParameters, isAuthRequired = false, parameters = null) return send(request).map { json -> val challenge = json["challenge"] as? Map<*, *> ?: throw Error.ParsingFailed diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt index f8e6d434e..92cb47ae3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt @@ -6,6 +6,7 @@ import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64.decode import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString import org.whispersystems.curve25519.Curve25519 data class OpenGroupMessageV2( @@ -45,10 +46,10 @@ data class OpenGroupMessageV2( fun sign(): OpenGroupMessageV2? { if (base64EncodedData.isEmpty()) return null - val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserKeyPair() ?: return null - if (sender != publicKey) return null + val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey to it.privateKey } + if (sender != publicKey.serialize().toHexString()) return null val signature = try { - curve.calculateSignature(privateKey, decode(base64EncodedData)) + curve.calculateSignature(privateKey.serialize(), decode(base64EncodedData)) } catch (e: Exception) { Log.w("Loki", "Couldn't sign open group message.", e) return null diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index cbd97e12f..7c1f9d45c 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -22,7 +22,7 @@ object MessageEncrypter { * * @return the encrypted message. */ - internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray{ + internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { val context = MessagingModuleConfiguration.shared.context val userED25519KeyPair = MessagingModuleConfiguration.shared.keyPairProvider() ?: throw Error.NoUserED25519KeyPair val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index bec77506e..36015b842 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -2,18 +2,20 @@ package org.session.libsession.snode +import android.content.Context import android.os.Build import com.goterl.lazysodium.LazySodiumAndroid import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.exceptions.SodiumException -import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.PwHash import com.goterl.lazysodium.interfaces.SecretBox +import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key import nl.komponents.kovenant.* import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsignal.crypto.getRandomElement import org.session.libsignal.database.LokiAPIDatabaseProtocol @@ -22,6 +24,7 @@ import org.session.libsignal.utilities.* import org.session.libsignal.utilities.Base64 import java.security.SecureRandom import java.util.* +import kotlin.Pair object SnodeAPI { private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } @@ -53,7 +56,7 @@ object SnodeAPI { private val targetSwarmSnodeCount = 2 private val useOnionRequests = true - internal val useTestnet = false + internal val useTestnet = true // Error internal sealed class Error(val description: String) : Exception(description) { @@ -100,9 +103,9 @@ object SnodeAPI { val parameters = mapOf( "method" to "get_n_service_nodes", "params" to mapOf( - "active_only" to true, - "limit" to 256, - "fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true ) + "active_only" to true, + "limit" to 256, + "fields" to mapOf("public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true) ) ) val deferred = deferred() @@ -178,8 +181,8 @@ object SnodeAPI { val base64EncodedNameHash = Base64.encodeBytes(nameHash) // Ask 3 different snodes for the Session ID associated with the given name hash val parameters = mapOf( - "endpoint" to "ons_resolve", - "params" to mapOf( "type" to 0, "name_hash" to base64EncodedNameHash ) + "endpoint" to "ons_resolve", + "params" to mapOf( "type" to 0, "name_hash" to base64EncodedNameHash ) ) val promises = (1..validationCount).map { getRandomSnode().bind { snode -> @@ -284,6 +287,13 @@ object SnodeAPI { } } + fun getNetworkTime(snode: Snode): Promise, Exception> { + return invoke(Snode.Method.Info, snode, null, emptyMap()).map { rawResponse -> + val timestamp = rawResponse["timestamp"] as? Long ?: -1 + snode to timestamp + } + } + fun sendMessage(message: SnodeMessage): Promise, Exception> { val destination = if (useTestnet) message.recipient.removing05PrefixIfNeeded() else message.recipient return retryIfNeeded(maxRetryCount) { @@ -321,6 +331,35 @@ object SnodeAPI { } } + fun deleteAllMessages(context: Context): Promise, Exception> { + + return retryIfNeeded(maxRetryCount) { + // considerations: timestamp off in retrying logic, not being able to re-sign with latest timestamp? do we just not retry this as it will be synchronous + val module = MessagingModuleConfiguration.shared + val userED25519KeyPair = module.keyPairProvider() ?: return@retryIfNeeded Promise.ofFail(Error.Generic) + val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.Generic) + + getSingleTargetSnode(userPublicKey).bind { snode -> + retryIfNeeded(maxRetryCount) { + getNetworkTime(snode).bind { (_, timestamp) -> + val signature = ByteArray(Sign.BYTES) + val data = (Snode.Method.DeleteAll.rawValue + timestamp.toString()).toByteArray() + sodium.cryptoSignDetached(signature, data, data.size.toLong(), userED25519KeyPair.secretKey.asBytes) + val deleteMessageParams = mapOf( + "pubkey" to userPublicKey, + "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, + "timestamp" to timestamp, + "signature" to Base64.encodeBytes(signature) + ) + invoke(Snode.Method.DeleteAll, snode, userPublicKey, deleteMessageParams).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) }.fail { e -> + Log.e("Loki", "Failed to clear data", e) + } + } + } + } + } + } + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { @@ -378,6 +417,43 @@ object SnodeAPI { } } } + + @Suppress("UNCHECKED_CAST") + private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map { + val swarms = rawResponse["swarm"] as? Map ?: return mapOf() + val swarmResponsesValid = swarms.mapNotNull { (nodePubKeyHex, rawMap) -> + val map = rawMap as? Map ?: return@mapNotNull null + + /** Deletes all messages owned by the given pubkey on this SN and broadcasts the delete request to + * all other swarm members. + * Returns dict of: + * - "swarms" dict mapping ed25519 pubkeys (in hex) of swarm members to dict values of: + * - "failed" and other failure keys -- see `recursive`. + * - "deleted": hashes of deleted messages. + * - "signature": signature of ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ), signed + * by the node's ed25519 pubkey. + */ + // failure + val failed = map["failed"] as? Boolean ?: false + val code = map["code"] as? String + val reason = map["reason"] as? String + + nodePubKeyHex to if (failed) { + Log.e("Loki", "Failed to delete all from $nodePubKeyHex with error code $code and reason $reason") + false + } else { + // success + val deleted = map["deleted"] as List // list of deleted hashes + val signature = map["signature"] as String + val nodePubKey = Key.fromHexString(nodePubKeyHex) + // signature of ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) + val message = (userPublicKey + timestamp.toString() + deleted.fold("") { a, v -> a + v }).toByteArray() + sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, nodePubKey.asBytes) + } + } + return swarmResponsesValid.toMap() + } + // endregion // Error Handling diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index 92c30095e..7b1592a52 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -7,7 +7,9 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { GetSwarm("get_snodes_for_pubkey"), GetMessages("retrieve"), SendMessage("store"), - OxenDaemonRPCCall("oxend_request") + OxenDaemonRPCCall("oxend_request"), + Info("info"), + DeleteAll("delete_all") } data class KeySet(val ed25519Key: String, val x25519Key: String)