diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt index 25c6dec9a..54c80227a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.loki.activities +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -12,9 +14,14 @@ import android.view.* import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast -import kotlinx.android.synthetic.main.activity_create_private_chat.* +import kotlinx.android.synthetic.main.activity_create_private_chat.loader +import kotlinx.android.synthetic.main.activity_create_private_chat.tabLayout +import kotlinx.android.synthetic.main.activity_create_private_chat.viewPager import kotlinx.android.synthetic.main.fragment_enter_public_key.* import network.loki.messenger.R +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.snode.SnodeAPI import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.ConversationActivity import org.session.libsession.utilities.Address @@ -48,6 +55,23 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC } // endregion + // region Updating + private fun showLoader() { + loader.visibility = View.VISIBLE + loader.animate().setDuration(150).alpha(1.0f).start() + } + + private fun hideLoader() { + loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { + + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + loader.visibility = View.GONE + } + }) + } + // endregion + // region Interaction override fun onOptionsItemSelected(item: MenuItem): Boolean { when(item.itemId) { @@ -60,8 +84,27 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC createPrivateChatIfPossible(hexEncodedPublicKey) } - fun createPrivateChatIfPossible(hexEncodedPublicKey: String) { - if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.invalid_session_id, Toast.LENGTH_SHORT).show() } + fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { + if (PublicKeyValidation.isValid(onsNameOrPublicKey)) { + createPrivateChat(onsNameOrPublicKey) + } else { + // This could be an ONS name + showLoader() + SnodeAPI.getSessionIDFor(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> + hideLoader() + this.createPrivateChat(hexEncodedPublicKey) + }.failUi { exception -> + hideLoader() + var message = resources.getString(R.string.fragment_enter_public_key_error_message) + exception.localizedMessage?.let { + message = it + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + } + } + + private fun createPrivateChat(hexEncodedPublicKey: String) { val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) val intent = Intent(this, ConversationActivity::class.java) intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/LinkDeviceActivity.kt index 7e4210246..20f8fcf79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/LinkDeviceActivity.kt @@ -13,9 +13,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.activity_create_private_chat.* -import kotlinx.android.synthetic.main.activity_create_private_chat.tabLayout -import kotlinx.android.synthetic.main.activity_create_private_chat.viewPager import kotlinx.android.synthetic.main.activity_link_device.* import kotlinx.android.synthetic.main.conversation_activity.* import kotlinx.android.synthetic.main.fragment_recovery_phrase.* diff --git a/app/src/main/res/layout/activity_create_private_chat.xml b/app/src/main/res/layout/activity_create_private_chat.xml index 6a1229648..d437a230f 100644 --- a/app/src/main/res/layout/activity_create_private_chat.xml +++ b/app/src/main/res/layout/activity_create_private_chat.xml @@ -1,14 +1,39 @@ - + android:layout_height="match_parent"> - + android:layout_height="match_parent" > - \ No newline at end of file + + + + + + + + + + + \ 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 8db4cfaf3..b8d6c578d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -719,8 +719,9 @@ Scan QR Code Scan a user\'s QR code to start a session. QR codes can be found by tapping the QR code icon in account settings. - Enter Session ID of recipient + Enter Session ID or ONS name Users can share their Session ID by going into their account settings and tapping "Share Session ID", or by sharing their QR code. + Please check the Session ID or ONS name and try again. Session needs camera access to scan QR codes Grant Camera Access diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 4f6aca389..a188b4a5a 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -430,7 +430,7 @@ object OnionRequestAPI { /** * Sends an onion request to `snode`. Builds new paths as needed. */ - internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String): Promise, Exception> { + internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String? = null): Promise, Exception> { val payload = mapOf( "method" to method.rawValue, "params" to parameters ) return sendOnionRequest(Destination.Snode(snode), payload).recover { exception -> val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException 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 3f9115572..f126871a4 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,24 +3,29 @@ package org.session.libsession.snode 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.utils.Key import nl.komponents.kovenant.* import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsignal.crypto.getRandomElement -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.HTTP import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Broadcaster -import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.removing05PrefixIfNeeded -import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.* -import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Base64 import java.security.SecureRandom +import java.util.* object SnodeAPI { + private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage private val broadcaster: Broadcaster @@ -54,10 +59,14 @@ object SnodeAPI { internal sealed class Error(val description: String) : Exception(description) { object Generic : Error("An error occurred.") object ClockOutOfSync : Error("Your clock is out of sync with the Service Node network.") + // ONS + object DecryptionFailed : Error("Couldn't decrypt ONS name.") + object HashingFailed : Error("Couldn't compute ONS name hash.") + object ValidationFailed : Error("ONS name validation failed.") } // Internal API - internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String, parameters: Map): RawResponsePromise { + internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String? = null, parameters: Map): RawResponsePromise { val url = "${snode.address}:${snode.port}/storage_rpc/v1" if (useOnionRequests) { return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey) @@ -153,6 +162,91 @@ object SnodeAPI { } // Public API + fun getSessionIDFor(onsName: String): Promise { + val deferred = deferred() + val promise = deferred.promise + val validationCount = 3 + val sessionIDByteCount = 33 + // Hash the ONS name using BLAKE2b + val onsName = onsName.toLowerCase(Locale.ENGLISH) + val nameAsData = onsName.toByteArray() + val nameHash = ByteArray(GenericHash.BYTES) + if (!sodium.cryptoGenericHash(nameHash, nameHash.size, nameAsData, nameAsData.size.toLong())) { + deferred.reject(Error.HashingFailed) + return promise + } + 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 ) + ) + val promises = (1..validationCount).map { + getRandomSnode().bind { snode -> + retryIfNeeded(maxRetryCount) { + invoke(Snode.Method.OxenDaemonRPCCall, snode, null, parameters) + } + + } + } + all(promises).success { results -> + val sessionIDs = mutableListOf() + for (json in results) { + val intermediate = json["result"] as? Map<*, *> + val hexEncodedCiphertext = intermediate?.get("encrypted_value") as? String + if (hexEncodedCiphertext != null) { + val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) + val isArgon2Based = (intermediate["nonce"] == null) + if (isArgon2Based) { + // Handle old Argon2-based encryption used before HF16 + val salt = ByteArray(PwHash.SALTBYTES) + val key: ByteArray + val nonce = ByteArray(SecretBox.NONCEBYTES) + val sessionIDAsData = ByteArray(sessionIDByteCount) + try { + key = Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes + } catch (e: SodiumException) { + deferred.reject(Error.HashingFailed) + return@success + } + if (!sodium.cryptoSecretBoxOpenEasy(sessionIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { + deferred.reject(Error.DecryptionFailed) + return@success + } + sessionIDs.add(Hex.toStringCondensed(sessionIDAsData)) + } else { + val hexEncodedNonce = intermediate["nonce"] as? String + if (hexEncodedNonce == null) { + deferred.reject(Error.Generic) + return@success + } + val nonce = Hex.fromStringCondensed(hexEncodedNonce) + val key = ByteArray(GenericHash.BYTES) + if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { + deferred.reject(Error.HashingFailed) + return@success + } + val sessionIDAsData = ByteArray(sessionIDByteCount) + if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(sessionIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { + deferred.reject(Error.DecryptionFailed) + return@success + } + sessionIDs.add(Hex.toStringCondensed(sessionIDAsData)) + } + } else { + deferred.reject(Error.Generic) + return@success + } + } + if (sessionIDs.size == validationCount && sessionIDs.toSet().size == 1) { + deferred.resolve(sessionIDs.first()) + } else { + deferred.reject(Error.ValidationFailed) + } + } + return promise + } + fun getTargetSnodes(publicKey: String): Promise, Exception> { // SecureRandom() should be cryptographically secure return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) } 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 af4a1f694..92c30095e 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -6,7 +6,8 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { public enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), GetMessages("retrieve"), - SendMessage("store") + SendMessage("store"), + OxenDaemonRPCCall("oxend_request") } data class KeySet(val ed25519Key: String, val x25519Key: String)