Merge pull request #574 from RyanRory/ONS-name-1

ONS Stage 1
This commit is contained in:
Niels Andriesse 2021-05-28 15:35:55 +10:00 committed by GitHub
commit de5d8506cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 26 deletions

View File

@ -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)

View File

@ -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.*

View File

@ -1,14 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.viewpager.widget.ViewPager
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/viewPager"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent" >
android:layout_height="match_parent">
<com.google.android.material.tabs.TabLayout
style="@style/Widget.Session.TabLayout"
android:id="@+id/tabLayout"
<androidx.viewpager.widget.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="@dimen/tab_bar_height" />
android:layout_height="match_parent" >
</androidx.viewpager.widget.ViewPager>
<com.google.android.material.tabs.TabLayout
style="@style/Widget.Session.TabLayout"
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="@dimen/tab_bar_height" />
</androidx.viewpager.widget.ViewPager>
<RelativeLayout
android:id="@+id/loader"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#A4000000"
android:visibility="gone"
android:alpha="0">
<com.github.ybq.android.spinkit.SpinKitView
style="@style/SpinKitView.Large.ThreeBounce"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_centerInParent="true"
app:SpinKit_Color="@android:color/white" />
</RelativeLayout>
</RelativeLayout>

View File

@ -719,8 +719,9 @@
<string name="activity_create_private_chat_scan_qr_code_tab_title">Scan QR Code</string>
<string name="activity_create_private_chat_scan_qr_code_explanation">Scan a user\'s QR code to start a session. QR codes can be found by tapping the QR code icon in account settings.</string>
<string name="fragment_enter_public_key_edit_text_hint">Enter Session ID of recipient</string>
<string name="fragment_enter_public_key_edit_text_hint">Enter Session ID or ONS name</string>
<string name="fragment_enter_public_key_explanation">Users can share their Session ID by going into their account settings and tapping "Share Session ID", or by sharing their QR code.</string>
<string name="fragment_enter_public_key_error_message">Please check the Session ID or ONS name and try again.</string>
<string name="fragment_scan_qr_code_camera_access_explanation">Session needs camera access to scan QR codes</string>
<string name="fragment_scan_qr_code_grant_camera_access_button_title">Grant Camera Access</string>

View File

@ -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<Map<*, *>, Exception> {
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String? = null): Promise<Map<*, *>, 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

View File

@ -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<String, String>): RawResponsePromise {
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String? = null, parameters: Map<String, Any>): 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<String, Exception> {
val deferred = deferred<String, Exception>()
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<String>()
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<List<Snode>, Exception> {
// SecureRandom() should be cryptographically secure
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }

View File

@ -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)