feat: Add Session Id blinding

Including modified version of lazysodium-android to expose missing libsodium functions, we could build from a fork which we still need to setup.
This commit is contained in:
ceokot 2022-03-25 05:34:31 +02:00
parent 2445418e3e
commit 85456b5ea2
28 changed files with 1097 additions and 54 deletions

View File

@ -104,7 +104,8 @@ dependencies {
implementation project(":libsession")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "org.whispersystems:curve25519-java:$curve25519Version"
implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation project(":liblazysodium")
// implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation "net.java.dev.jna:jna:5.8.0@aar"
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"

View File

@ -0,0 +1,28 @@
package network.loki.messenger
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.session.libsession.messaging.utilities.SodiumUtilities
@RunWith(AndroidJUnit4::class)
class SodiumUtilitiesTest {
private val serverPublicKey = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d"
private val pubKey = Key.fromHexString("bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")
private val secKey = Key.fromHexString("c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9")
@Test
fun blindedKeyPair() {
val blindedKey = "98932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5"
val keyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, KeyPair(pubKey, secKey))!!
assertThat(keyPair.publicKey.asHexString.lowercase(), equalTo(blindedKey))
}
}

View File

@ -57,7 +57,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
@ -500,7 +500,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return
OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() }
OpenGroupApiV4.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() }
}
// called from onCreate

View File

@ -7,7 +7,7 @@ import android.view.View
import android.widget.LinearLayout
import network.loki.messenger.databinding.ViewMentionCandidateBinding
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView : LinearLayout {
@ -34,7 +34,7 @@ class MentionCandidateView : LinearLayout {
profilePictureView.glide = glide!!
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!)
val isUserModerator = OpenGroupApiV4.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE

View File

@ -7,7 +7,7 @@ import android.view.View
import android.widget.RelativeLayout
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView : RelativeLayout {
@ -34,7 +34,7 @@ class MentionCandidateView : RelativeLayout {
profilePictureView.glide = glide!!
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!)
val isUserModerator = OpenGroupApiV4.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE

View File

@ -5,7 +5,7 @@ import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import network.loki.messenger.R
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
@ -41,13 +41,13 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) {
if (openGroup == null) { return true }
if (allSentByCurrentUser) { return true }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
}
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser }
if (allSentByCurrentUser) { return true }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
}
fun userCanBanSelectedUsers(): Boolean {
if (openGroup == null) { return false }
@ -55,7 +55,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
if (anySentByCurrentUser) { return false } // Users can't ban themselves
val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet()
if (selectedUsers.size > 1) { return false }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
}
// Delete message
menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems()

View File

@ -24,7 +24,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.utilities.ViewUtil
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
@ -127,7 +127,7 @@ class VisibleMessageView : LinearLayout {
}
if (thread.isOpenGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server)
val isModerator = OpenGroupApiV4.isUserModerator(senderSessionID, openGroup.room, openGroup.server)
binding.moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE
} else {
binding.moderatorIconImageView.visibility = View.INVISIBLE

View File

@ -4,19 +4,19 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.thoughtcrime.securesms.util.State
typealias DefaultGroups = List<OpenGroupAPIV2.DefaultGroup>
typealias DefaultGroups = List<OpenGroupApiV4.DefaultGroup>
typealias GroupState = State<DefaultGroups>
class DefaultGroupsViewModel : ViewModel() {
init {
OpenGroupAPIV2.getDefaultRoomsIfNeeded()
OpenGroupApiV4.getDefaultRoomsIfNeeded()
}
val defaultRooms = OpenGroupAPIV2.defaultRooms.map<DefaultGroups, GroupState> {
val defaultRooms = OpenGroupApiV4.defaultRooms.map<DefaultGroups, GroupState> {
State.Success(it)
}.onStart {
emit(State.Loading)

View File

@ -25,7 +25,7 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityJoinPublicChatBinding
import network.loki.messenger.databinding.FragmentEnterChatUrlBinding
import okhttp3.HttpUrl
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup
import org.session.libsession.messaging.open_groups.OpenGroupApiV4.DefaultGroup
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient

View File

@ -4,18 +4,17 @@ import android.content.Context
import androidx.annotation.WorkerThread
import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.open_groups.OpenGroupV2
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4
import org.session.libsession.utilities.Util
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.BitmapUtil
import java.util.concurrent.Executors
object OpenGroupManager {
private val executorService = Executors.newScheduledThreadPool(4)
private var pollers = mutableMapOf<String, OpenGroupPollerV2>() // One for each server
private var pollers = mutableMapOf<String, OpenGroupPollerV4>() // One for each server
private var isPolling = false
val isAllCaughtUp: Boolean
@ -42,7 +41,7 @@ object OpenGroupManager {
val servers = storage.getAllV2OpenGroups().values.map { it.server }.toSet()
servers.forEach { server ->
pollers[server]?.stop() // Shouldn't be necessary
val poller = OpenGroupPollerV2(server, executorService)
val poller = OpenGroupPollerV4(server, executorService)
poller.startIfNeeded()
pollers[server] = poller
}
@ -68,18 +67,20 @@ object OpenGroupManager {
// Store the public key
storage.setOpenGroupPublicKey(server,publicKey)
// Get an auth token
OpenGroupAPIV2.getAuthToken(room, server).get()
OpenGroupApiV4.getAuthToken(room, server).get()
// Get capabilities
val capabilities = OpenGroupApiV4.getCapabilities(room, server).get()
// Get group info
val info = OpenGroupAPIV2.getInfo(room, server).get()
val info = OpenGroupApiV4.getInfo(room, server).get()
// Create the group locally if not available already
if (threadID < 0) {
threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId
}
val openGroup = OpenGroupV2(server, room, info.name, publicKey)
val openGroup = OpenGroupV2(server, room, info.name, publicKey, capabilities)
threadDB.setOpenGroupChat(openGroup, threadID)
// Start the poller if needed
pollers[server]?.startIfNeeded() ?: run {
val poller = OpenGroupPollerV2(server, executorService)
val poller = OpenGroupPollerV4(server, executorService)
Util.runOnMain { poller.startIfNeeded() }
pollers[server] = poller
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.WorkerThread
import org.greenrobot.eventbus.EventBus
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@ -29,8 +30,8 @@ object OpenGroupUtilities {
throw IllegalStateException("Attempt to update open group info for non-existent DB record: $groupId")
}
val info = OpenGroupAPIV2.getInfo(room, server).get() // store info again?
OpenGroupAPIV2.getMemberCount(room, server).get()
val info = OpenGroupApiV4.getInfo(room, server).get() // store info again?
OpenGroupApiV4.getMemberCount(room, server).get()
EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room))
}

View File

@ -3,14 +3,20 @@ package org.thoughtcrime.securesms.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.*
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
@ -68,9 +74,9 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
val v2OpenGroupServers = v2OpenGroups.map { it.value.server }.toSet()
for (server in v2OpenGroupServers) {
val poller = OpenGroupPollerV2(server, null)
val poller = OpenGroupPollerV4(server, null)
poller.hasStarted = true
promises.add(poller.poll(true))
promises.add(poller.poll())
}
// Wait until all the promises are resolved

View File

@ -7,7 +7,7 @@ import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
@ -154,7 +154,7 @@ class DefaultConversationRepository @Inject constructor(
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)
if (openGroup != null) {
lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID ->
OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server)
OpenGroupApiV4.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success {
messageDataProvider.deleteMessage(message.id, !message.isMms)
continuation.resume(ResultOf.Success(Unit))
@ -206,7 +206,7 @@ class DefaultConversationRepository @Inject constructor(
messageServerIDs[messageServerID] = message
}
for ((messageServerID, message) in messageServerIDs) {
OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server)
OpenGroupApiV4.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success {
messageDataProvider.deleteMessage(message.id, !message.isMms)
}.fail { error ->
@ -229,7 +229,7 @@ class DefaultConversationRepository @Inject constructor(
suspendCoroutine { continuation ->
val sessionID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!
OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server)
OpenGroupApiV4.ban(sessionID, openGroup.room, openGroup.server)
.success {
continuation.resume(ResultOf.Success(Unit))
}.fail { error ->
@ -241,7 +241,7 @@ class DefaultConversationRepository @Inject constructor(
suspendCoroutine { continuation ->
val sessionID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!
OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server)
OpenGroupApiV4.banAndDeleteAll(sessionID, openGroup.room, openGroup.server)
.success {
continuation.resume(ResultOf.Success(Unit))
}.fail { error ->

View File

@ -0,0 +1,2 @@
configurations.maybeCreate("default")
artifacts.add("default", file('lazysodium.aar'))

Binary file not shown.

View File

@ -18,7 +18,8 @@ android {
dependencies {
implementation project(":libsignal")
implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation project(":liblazysodium")
// implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation "net.java.dev.jna:jna:5.8.0@aar"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation 'androidx.core:core-ktx:1.3.2'

View File

@ -6,10 +6,10 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.RequestBody
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
@ -53,7 +53,7 @@ object FileServerAPIV2 {
}
private fun send(request: Request): Promise<Map<*, *>, Exception> {
val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupAPIV2.Error.InvalidURL)
val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupApiV4.Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
.host(url.host())
@ -87,7 +87,7 @@ object FileServerAPIV2 {
val parameters = mapOf( "file" to base64EncodedFile )
val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters)
return send(request).map { json ->
json["result"] as? Long ?: throw OpenGroupAPIV2.Error.ParsingFailed
json["result"] as? Long ?: throw OpenGroupApiV4.Error.ParsingFailed
}
}

View File

@ -3,6 +3,7 @@ package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
@ -102,7 +103,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
} else {
val url = HttpUrl.parse(attachment.url)!!
val fileID = url.pathSegments().last()
OpenGroupAPIV2.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let {
OpenGroupApiV4.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let {
tempFile.writeBytes(it)
}
}

View File

@ -9,6 +9,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.DecodedAudio
@ -53,7 +54,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong())
if (v2OpenGroup != null) {
val keyAndResult = upload(attachment, v2OpenGroup.server, false) {
OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server)
OpenGroupApiV4.upload(it, v2OpenGroup.room, v2OpenGroup.server)
}
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
} else {

View File

@ -1,7 +1,7 @@
package org.session.libsession.messaging.jobs
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.GroupUtil
@ -15,8 +15,8 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
override fun execute() {
val storage = MessagingModuleConfiguration.shared.storage
try {
val info = OpenGroupAPIV2.getInfo(room, server).get()
val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(info.id, server).get()
val info = OpenGroupApiV4.getInfo(room, server).get()
val bytes = OpenGroupApiV4.downloadOpenGroupProfilePicture(info.id, server).get()
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis())

View File

@ -0,0 +1,665 @@
package org.session.libsession.messaging.open_groups
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming
import com.fasterxml.jackson.databind.type.TypeFactory
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import kotlinx.coroutines.flow.MutableSharedFlow
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4.Companion.maxInactivityPeriod
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.AESGCM
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Base64.decode
import org.session.libsignal.utilities.Base64.encodeBytes
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.HTTP.Verb.DELETE
import org.session.libsignal.utilities.HTTP.Verb.GET
import org.session.libsignal.utilities.HTTP.Verb.POST
import org.session.libsignal.utilities.HTTP.Verb.PUT
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import org.whispersystems.curve25519.Curve25519
import java.util.concurrent.TimeUnit
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
object OpenGroupApiV4 {
private val moderators: HashMap<String, Set<String>> =
hashMapOf() // Server URL to (channel ID to set of moderator IDs)
private val curve = Curve25519.getInstance(Curve25519.BEST)
val defaultRooms = MutableSharedFlow<List<DefaultGroup>>(replay = 1)
private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>()
private var hasUpdatedLastOpenDate = false
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
private val timeSinceLastOpen by lazy {
val context = MessagingModuleConfiguration.shared.context
val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context)
val now = System.currentTimeMillis()
now - lastOpenDate
}
private const val defaultServerPublicKey =
"a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val defaultServer = "http://116.203.70.33"
sealed class Error(message: String) : Exception(message) {
object Generic : Error("An error occurred.")
object ParsingFailed : Error("Invalid response.")
object DecryptionFailed : Error("Couldn't decrypt response.")
object SigningFailed : Error("Couldn't sign message.")
object InvalidURL : Error("Invalid URL.")
object NoPublicKey : Error("Couldn't find server public key.")
object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.")
}
data class DefaultGroup(val id: String, val name: String, val image: ByteArray?) {
val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey"
}
data class Info(val id: String, val name: String, val imageID: String?)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class BatchRequest(
val roomID: String,
val authToken: String,
val fromDeletionServerID: Long?,
val fromMessageServerID: Long?
)
data class BatchResult(
val messages: List<OpenGroupMessageV2>,
val deletions: List<MessageDeletion>,
val moderators: List<String>
)
data class MessageDeletion(
@JsonProperty("id")
val id: Long = 0,
@JsonProperty("deleted_message_id")
val deletedMessageServerID: Long = 0
) {
companion object {
val empty = MessageDeletion()
}
}
data class Request(
val verb: HTTP.Verb,
val room: String?,
val server: String,
val endpoint: String,
val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
/**
* Always `true` under normal circumstances. You might want to disable
* this when running over Lokinet.
*/
val useOnionRouting: Boolean = true,
val isBlinded: Boolean = true
)
private fun createBody(parameters: Any?): RequestBody? {
if (parameters == null) return null
val parametersAsJSON = JsonUtil.toJson(parameters)
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
}
private fun send(request: Request): Promise<Map<*, *>, Exception> {
val url = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
.host(url.host())
.port(url.port())
.addPathSegments(request.endpoint)
if (request.verb == GET) {
for ((key, value) in request.queryParameters) {
urlBuilder.addQueryParameter(key, value)
}
}
fun execute(token: String?): Promise<Map<*, *>, Exception> {
val publicKey =
MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server)
?: return Promise.ofFail(Error.NoPublicKey)
val ed25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
?: return Promise.ofFail(Error.NoEd25519KeyPair)
val urlRequest = urlBuilder.build()
val headers = request.headers.toMutableMap()
val nonce = sodium.nonce(16)
val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
var pubKey = ""
var signature = ByteArray(0)
if (request.isBlinded) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SodiumUtilities.SessionId(
SodiumUtilities.IdPrefix.BLINDED,
keyPair.publicKey.asBytes
).hexString
signature = SodiumUtilities.sogsSignature(
urlRequest.toString().toByteArray(),
ed25519KeyPair.secretKey.asBytes,
keyPair.secretKey.asBytes,
keyPair.publicKey.asBytes
) ?: return Promise.ofFail(Error.SigningFailed)
} ?: return Promise.ofFail(Error.SigningFailed)
} else {
pubKey = SodiumUtilities.SessionId(
SodiumUtilities.IdPrefix.UN_BLINDED,
ed25519KeyPair.publicKey.asBytes
).hexString
signature = ByteArray(0)
}
headers["X-SOGS-Nonce"] = encodeBytes(nonce)
headers["X-SOGS-Timestamp"] = "$timestamp"
headers["X-SOGS-Pubkey"] = pubKey
headers["X-SOGS-Signature"] = encodeBytes(signature)
headers.forEach { entry -> Log.d("Loki", "${entry.key}: ${entry.value}") }
val requestBuilder = okhttp3.Request.Builder()
.url(urlRequest)
.headers(Headers.of(headers))
if (request.isAuthRequired) {
if (token.isNullOrEmpty()) throw IllegalStateException("No auth token for request.")
requestBuilder.header("Authorization", token)
}
when (request.verb) {
GET -> requestBuilder.get()
PUT -> requestBuilder.put(createBody(request.parameters)!!)
POST -> requestBuilder.post(createBody(request.parameters)!!)
DELETE -> requestBuilder.delete(createBody(request.parameters))
}
if (!request.room.isNullOrEmpty()) {
requestBuilder.header("Room", request.room)
}
if (request.useOnionRouting) {
return OnionRequestAPI.sendOnionRequest(
requestBuilder.build(),
request.server,
publicKey
).fail { e ->
// A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an
// indication that the token we're using has expired. Note that a 403 has a different meaning; it means that
// we provided a valid token but it doesn't have a high enough permission level for the route in question.
if (e is OnionRequestAPI.HTTPRequestFailedAtDestinationException && e.statusCode == 401) {
val storage = MessagingModuleConfiguration.shared.storage
if (request.room != null) {
storage.removeAuthToken(request.room, request.server)
}
}
}
} else {
return Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
}
}
return if (request.isAuthRequired) {
getAuthToken(request.room!!, request.server).bind { execute(it) }
} else {
execute(null)
}
}
fun downloadOpenGroupProfilePicture(
roomID: String,
server: String
): Promise<ByteArray, Exception> {
val request = Request(
verb = GET,
room = roomID,
server = server,
endpoint = "rooms/$roomID/image",
isAuthRequired = false
)
return send(request).map { json ->
val result = json["result"] as? String ?: throw Error.ParsingFailed
decode(result)
}
}
// region Authorization
fun getAuthToken(room: String, server: String): Promise<String, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
return storage.getAuthToken(room, server)?.let {
Promise.of(it)
} ?: run {
requestNewAuthToken(room, server)
.bind { claimAuthToken(it, room, server) }
.success { authToken ->
storage.setAuthToken(room, server, authToken)
}
}
}
fun requestNewAuthToken(room: String, server: String): Promise<String, Exception> {
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.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
val base64EncodedCiphertext =
challenge["ciphertext"] as? String ?: throw Error.ParsingFailed
val base64EncodedEphemeralPublicKey =
challenge["ephemeral_public_key"] as? String ?: throw Error.ParsingFailed
val ciphertext = decode(base64EncodedCiphertext)
val ephemeralPublicKey = decode(base64EncodedEphemeralPublicKey)
val symmetricKey = AESGCM.generateSymmetricKey(ephemeralPublicKey, privateKey)
val tokenAsData = try {
AESGCM.decrypt(ciphertext, symmetricKey)
} catch (e: Exception) {
throw Error.DecryptionFailed
}
tokenAsData.toHexString()
}
}
fun claimAuthToken(
authToken: String,
room: String,
server: String
): Promise<String, Exception> {
val parameters =
mapOf("public_key" to MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!)
val headers = mapOf("Authorization" to authToken)
val request = Request(
verb = POST, room = room, server = server, endpoint = "claim_auth_token",
parameters = parameters, headers = headers, isAuthRequired = false
)
return send(request).map { authToken }
}
// endregion
// region Upload/Download
fun upload(file: ByteArray, room: String, server: String): Promise<Long, Exception> {
val base64EncodedFile = encodeBytes(file)
val parameters = mapOf("file" to base64EncodedFile)
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = "files",
parameters = parameters
)
return send(request).map { json ->
(json["result"] as? Number)?.toLong() ?: throw Error.ParsingFailed
}
}
fun download(file: Long, room: String, server: String): Promise<ByteArray, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "files/$file")
return send(request).map { json ->
val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed
decode(base64EncodedFile) ?: throw Error.ParsingFailed
}
}
// endregion
// region Sending
fun send(
message: OpenGroupMessageV2,
room: String,
server: String
): Promise<OpenGroupMessageV2, Exception> {
val signedMessage = message.sign() ?: return Promise.ofFail(Error.SigningFailed)
val jsonMessage = signedMessage.toJSON()
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = "messages",
parameters = jsonMessage
)
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessage = json["message"] as? Map<String, Any>
?: throw Error.ParsingFailed
val result = OpenGroupMessageV2.fromJSON(rawMessage) ?: throw Error.ParsingFailed
val storage = MessagingModuleConfiguration.shared.storage
storage.addReceivedMessageTimestamp(result.sentTimestamp)
result
}
}
// endregion
// region Messages
fun getMessages(room: String, server: String): Promise<List<OpenGroupMessageV2>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastMessageServerID(room, server)?.let { lastId ->
queryParameters += "from_server_id" to lastId.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = "messages",
queryParameters = queryParameters
)
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessages =
json["messages"] as? List<Map<String, Any>>
?: throw Error.ParsingFailed
parseMessages(room, server, rawMessages)
}
}
private fun parseMessages(
room: String,
server: String,
rawMessages: List<Map<*, *>>
): List<OpenGroupMessageV2> {
val messages = rawMessages.mapNotNull { json ->
json as Map<String, Any>
try {
val message = OpenGroupMessageV2.fromJSON(json) ?: return@mapNotNull null
if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null
val sender = message.sender
val data = decode(message.base64EncodedData)
val signature = decode(message.base64EncodedSignature)
val publicKey = Hex.fromStringCondensed(sender.removing05PrefixIfNeeded())
val isValid = curve.verifySignature(publicKey, data, signature)
if (!isValid) {
Log.d("Loki", "Ignoring message with invalid signature.")
return@mapNotNull null
}
message
} catch (e: Exception) {
null
}
}
return messages
}
// endregion
// region Message Deletion
@JvmStatic
fun deleteMessage(serverID: Long, room: String, server: String): Promise<Unit, Exception> {
val request =
Request(verb = DELETE, room = room, server = server, endpoint = "messages/$serverID")
return send(request).map {
Log.d("Loki", "Message deletion successful.")
}
}
fun getDeletedMessages(
room: String,
server: String
): Promise<List<MessageDeletion>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastDeletionServerID(room, server)?.let { last ->
queryParameters["from_server_id"] = last.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = "deleted_messages",
queryParameters = queryParameters
)
return send(request).map { json ->
val type = TypeFactory.defaultInstance()
.constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["ids"])
val serverIDs = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type)
?: throw Error.ParsingFailed
val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0
val serverID = serverIDs.maxByOrNull { it.id } ?: MessageDeletion.empty
if (serverID.id > lastMessageServerId) {
storage.setLastDeletionServerID(room, server, serverID.id)
}
serverIDs
}
}
// endregion
// region Moderation
private fun handleModerators(serverRoomId: String, moderatorList: List<String>) {
moderators[serverRoomId] = moderatorList.toMutableSet()
}
fun getModerators(room: String, server: String): Promise<List<String>, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "moderators")
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val moderatorsJson = json["moderators"] as? List<String>
?: throw Error.ParsingFailed
val id = "$server.$room"
handleModerators(id, moderatorsJson)
moderatorsJson
}
}
@JvmStatic
fun ban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val parameters = mapOf("public_key" to publicKey)
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = "block_list",
parameters = parameters
)
return send(request).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val parameters = mapOf("public_key" to publicKey)
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = "ban_and_delete_all",
parameters = parameters
)
return send(request).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun unban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val request =
Request(verb = DELETE, room = room, server = server, endpoint = "block_list/$publicKey")
return send(request).map {
Log.d("Loki", "Unbanned user: $publicKey from: $server.$room")
}
}
@JvmStatic
fun isUserModerator(publicKey: String, room: String, server: String): Boolean =
moderators["$server.$room"]?.contains(publicKey) ?: false
// endregion
// region General
@Suppress("UNCHECKED_CAST")
fun batch(
rooms: List<String>,
server: String
): Promise<Map<String, BatchResult>, Exception> {
val authTokenRequests = rooms.associateWith { room -> getAuthToken(room, server) }
val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context
val timeSinceLastOpen = this.timeSinceLastOpen
val useMessageLimit = (hasPerformedInitialPoll[server] != true
&& timeSinceLastOpen > maxInactivityPeriod)
hasPerformedInitialPoll[server] = true
if (!hasUpdatedLastOpenDate) {
hasUpdatedLastOpenDate = true
TextSecurePreferences.setLastOpenDate(context)
}
val requests = rooms.mapNotNull { room ->
val authToken = try {
authTokenRequests[room]?.get()
} catch (e: Exception) {
Log.e("Loki", "Failed to get auth token for $room.", e)
null
} ?: return@mapNotNull null
BatchRequest(
roomID = room,
authToken = authToken,
fromDeletionServerID = if (useMessageLimit) null else storage.getLastDeletionServerID(
room,
server
),
fromMessageServerID = if (useMessageLimit) null else storage.getLastMessageServerID(
room,
server
)
)
}
val request = Request(
verb = POST,
room = null,
server = server,
endpoint = "batch",
isAuthRequired = false,
parameters = mapOf("requests" to requests)
)
return send(request = request).map { json ->
val results = json["results"] as? List<*> ?: throw Error.ParsingFailed
results.mapNotNull { json ->
if (json !is Map<*, *>) return@mapNotNull null
val roomID = json["room_id"] as? String ?: return@mapNotNull null
// A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an
// indication that the token we're using has expired. Note that a 403 has a different meaning; it means that
// we provided a valid token but it doesn't have a high enough permission level for the route in question.
val statusCode = json["status_code"] as? Int ?: return@mapNotNull null
if (statusCode == 401) {
// delete auth token and return null
storage.removeAuthToken(roomID, server)
}
// Moderators
val moderators = json["moderators"] as? List<String> ?: return@mapNotNull null
handleModerators("$server.$roomID", moderators)
// Deletions
val type = TypeFactory.defaultInstance()
.constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["deletions"])
val deletions = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type)
?: throw Error.ParsingFailed
// Messages
val rawMessages =
json["messages"] as? List<Map<String, Any>> ?: return@mapNotNull null
val messages = parseMessages(roomID, server, rawMessages)
roomID to BatchResult(
messages = messages,
deletions = deletions,
moderators = moderators
)
}.toMap()
}
}
fun getDefaultRoomsIfNeeded(): Promise<List<DefaultGroup>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey)
return getAllRooms(defaultServer).map { groups ->
val earlyGroups = groups.map { group ->
DefaultGroup(group.id, group.name, null)
}
// See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results
defaultRooms.replayCache.firstOrNull()?.let { replayed ->
if (replayed.none { it.image?.isNotEmpty() == true }) {
defaultRooms.tryEmit(earlyGroups)
}
}
val images = groups.associate { group ->
group.id to downloadOpenGroupProfilePicture(group.id, defaultServer)
}
groups.map { group ->
val image = try {
images[group.id]!!.get()
} catch (e: Exception) {
// No image or image failed to download
null
}
DefaultGroup(group.id, group.name, image)
}
}.success { new ->
defaultRooms.tryEmit(new)
}
}
fun getInfo(room: String, server: String): Promise<Info, Exception> {
val request = Request(
verb = GET,
room = null,
server = server,
endpoint = "rooms/$room",
isAuthRequired = false
)
return send(request).map { json ->
val rawRoom = json["room"] as? Map<*, *> ?: throw Error.ParsingFailed
val id = rawRoom["id"] as? String ?: throw Error.ParsingFailed
val name = rawRoom["name"] as? String ?: throw Error.ParsingFailed
val imageID = rawRoom["image_id"] as? String
Info(id = id, name = name, imageID = imageID)
}
}
fun getAllRooms(server: String): Promise<List<Info>, Exception> {
val request = Request(
verb = GET,
room = null,
server = server,
endpoint = "rooms",
isAuthRequired = false
)
return send(request).map { json ->
val rawRooms = json["rooms"] as? List<Map<*, *>> ?: throw Error.ParsingFailed
rawRooms.mapNotNull {
val roomJson = it as? Map<*, *> ?: return@mapNotNull null
val id = roomJson["id"] as? String ?: return@mapNotNull null
val name = roomJson["name"] as? String ?: return@mapNotNull null
val imageID = roomJson["image_id"] as? String
Info(id, name, imageID)
}
}
}
fun getMemberCount(room: String, server: String): Promise<Int, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "active_users")
return send(request).map { json ->
val activeUserCount = json["active_users"] as? Int ?: throw Error.ParsingFailed
val storage = MessagingModuleConfiguration.shared.storage
storage.setUserCount(room, server, activeUserCount)
activeUserCount
}
}
fun getCapabilities(room: String, server: String): Promise<List<String>, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "capabilities")
return send(request).map { json ->
json["capabilities"] as? List<String> ?: throw Error.ParsingFailed
}
}
// endregion
}

View File

@ -9,15 +9,17 @@ data class OpenGroupV2(
val room: String,
val id: String,
val name: String,
val publicKey: String
val publicKey: String,
val capabilities: List<String>
) {
constructor(server: String, room: String, name: String, publicKey: String) : this(
constructor(server: String, room: String, name: String, publicKey: String, capabilities: List<String>) : this(
server = server,
room = room,
id = "$server.$room",
name = name,
publicKey = publicKey,
capabilities = capabilities,
)
companion object {
@ -30,7 +32,8 @@ data class OpenGroupV2(
val server = json.get("server").asText().toLowerCase(Locale.US)
val displayName = json.get("displayName").asText()
val publicKey = json.get("publicKey").asText()
OpenGroupV2(server, room, displayName, publicKey)
val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList()
OpenGroupV2(server, room, displayName, publicKey, capabilities)
} catch (e: Exception) {
Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e);
null
@ -44,6 +47,7 @@ data class OpenGroupV2(
"server" to server,
"displayName" to name,
"publicKey" to publicKey,
"capabilities" to capabilities.joinToString(","),
)
val joinURL: String get() = "$server/$room?public_key=$publicKey"

View File

@ -235,7 +235,7 @@ object MessageSender {
sentTimestamp = message.sentTimestamp!!,
base64EncodedData = Base64.encodeBytes(plaintext),
)
OpenGroupAPIV2.send(openGroupMessage,room,server).success {
OpenGroupApiV4.send(openGroupMessage,room,server).success {
message.openGroupServerMessageID = it.serverID
handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp)
deferred.resolve(Unit)

View File

@ -0,0 +1,126 @@
package org.session.libsession.messaging.sending_receiving.pollers
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.open_groups.OpenGroupApiV4
import org.session.libsession.messaging.open_groups.OpenGroupMessageV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.successBackground
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import kotlin.math.max
class OpenGroupPollerV4(private val server: String, private val executorService: ScheduledExecutorService?) {
var hasStarted = false
var isCaughtUp = false
var secondToLastJob: MessageReceiveJob? = null
private var future: ScheduledFuture<*>? = null
companion object {
private const val pollInterval: Long = 4000L
const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000
}
fun startIfNeeded() {
if (hasStarted) { return }
hasStarted = true
future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS)
}
fun stop() {
future?.cancel(false)
hasStarted = false
}
fun poll(): Promise<Unit, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val rooms = storage.getAllV2OpenGroups().values.filter { it.server == server }.map { it.room }
rooms.forEach { downloadGroupAvatarIfNeeded(it) }
return OpenGroupApiV4.batch(rooms, server).successBackground { responses ->
responses.forEach { (room, response) ->
val openGroupID = "$server.$room"
handleNewMessages(room, openGroupID, response.messages)
handleDeletedMessages(room, openGroupID, response.deletions)
if (secondToLastJob == null && !isCaughtUp) {
isCaughtUp = true
}
}
}.always {
executorService?.schedule(this@OpenGroupPollerV4::poll, pollInterval, TimeUnit.MILLISECONDS)
}.map { }
}
private fun handleNewMessages(
room: String,
openGroupID: String,
messages: List<OpenGroupMessageV2>
) {
val storage = MessagingModuleConfiguration.shared.storage
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
// check thread still exists
val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1
val threadExists = threadId >= 0
if (!hasStarted || !threadExists) { return }
val envelopes = messages.sortedBy { it.serverID!! }.map { message ->
val senderPublicKey = message.sender!!
val builder = SignalServiceProtos.Envelope.newBuilder()
builder.type = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE
builder.source = senderPublicKey
builder.sourceDevice = 1
builder.content = message.toProto().toByteString()
builder.timestamp = message.sentTimestamp
builder.build() to message.serverID
}
envelopes.chunked(256).forEach { list ->
val parameters = list.map { (message, serverId) ->
MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId)
}
JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID))
}
val currentLastMessageServerID = storage.getLastMessageServerID(room, server) ?: 0
val actualMax = max(messages.mapNotNull { it.serverID }.maxOrNull() ?: 0, currentLastMessageServerID)
if (actualMax > 0) {
storage.setLastMessageServerID(room, server, actualMax)
}
}
private fun handleDeletedMessages(room: String, openGroupID: String, deletions: List<OpenGroupApiV4.MessageDeletion>) {
val storage = MessagingModuleConfiguration.shared.storage
val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return
val deletedMessageIDs = deletions.mapNotNull { deletion ->
dataProvider.getMessageID(deletion.deletedMessageServerID, threadID)
}
deletedMessageIDs.forEach { (messageId, isSms) ->
MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms)
}
val currentMax = storage.getLastDeletionServerID(room, server) ?: 0L
val latestMax = deletions.map { it.id }.maxOrNull() ?: 0L
if (latestMax > currentMax && latestMax != 0L) {
storage.setLastDeletionServerID(room, server, latestMax)
}
}
private fun downloadGroupAvatarIfNeeded(room: String) {
val storage = MessagingModuleConfiguration.shared.storage
if (storage.getGroupAvatarDownloadJob(server, room) != null) return
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.getGroup(groupId)?.let {
if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) {
JobQueue.shared.add(GroupAvatarDownloadJob(room, server))
}
}
}
}

View File

@ -0,0 +1,202 @@
package org.session.libsession.messaging.utilities
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.Hash
import com.goterl.lazysodium.interfaces.Sign
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.toHexString
import kotlin.experimental.inv
object SodiumUtilities {
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
/* 64-byte blake2b hash then reduce to get the blinding factor */
private fun generateBlindingFactor(serverPublicKey: String): ByteArray? {
// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey)
val serverPubKeyHash = ByteArray(GenericHash.BYTES_MAX)
if (!sodium.cryptoGenericHash(serverPubKeyHash, serverPubKeyHash.size, serverPubKeyData, serverPubKeyData.size.toLong())) {
return null
}
// Reduce the server public key into an ed25519 scalar (`k`)
val x25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
sodium.cryptoCoreEd25519ScalarReduce(x25519PublicKey, serverPubKeyHash)
return if (x25519PublicKey.any { it.toInt() != 0 }) {
x25519PublicKey
} else null
}
/*
Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
same secret scalar secret (and so this is just the most convenient way to get 'a' out of
a sodium Ed25519 secret key)
*/
private fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? {
// a = s.to_curve25519_private_key().encode()
val aBytes = ByteArray(Sign.PUBLICKEYBYTES)
return if (sodium.convertSecretKeyEd25519ToCurve25519(aBytes, secretKey)) {
aBytes
} else null
}
/* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */
fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? {
// if (edKeyPair.publicKey.asBytes.size != Sign.PUBLICKEYBYTES ||
// edKeyPair.secretKey.asBytes.size != Sign.SECRETKEYBYTES) return null
val kBytes = generateBlindingFactor(serverPublicKey)
val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes)
// Generate the blinded key pair `ka`, `kA`
val kaBytes = ByteArray(Sign.SECRETKEYBYTES)
sodium.cryptoCoreEd25519ScalarMul(kaBytes, kBytes, aBytes)
if (kaBytes.all { it.toInt() == 0 }) {
return null
}
val kABytes = ByteArray(Sign.PUBLICKEYBYTES)
sodium.cryptoScalarMultE25519BaseNoClamp(kABytes, kaBytes)
return if (kABytes.any { it.toInt() != 0 }) {
KeyPair(Key.fromBytes(kABytes), Key.fromBytes(kaBytes))
} else {
null
}
}
/*
Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the
construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded
pubkeys (this doesn't affect verification at all)
*/
fun sogsSignature(
message: ByteArray,
secretKey: ByteArray,
ka: ByteArray, /*blindedSecretKey*/
kA: ByteArray /*blindedPublicKey*/
): ByteArray? {
// H_rh = sha512(s.encode()).digest()[32:]
val h_rh = ByteArray(Hash.SHA512_BYTES)
if (!sodium.cryptoHashSha512(h_rh, secretKey, secretKey.size.toLong())) return null
// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts))
val combinedData = h_rh + kA + message
val combinedHash = ByteArray(Hash.SHA512_BYTES)
if (!sodium.cryptoHashSha512(combinedHash, combinedData, combinedData.size.toLong())) return null
val rHash = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
sodium.cryptoCoreEd25519ScalarReduce(rHash, combinedHash)
if (rHash.all { it.toInt() == 0 }) {
return null
}
// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r)
val sig_R = ByteArray(Sign.SECRETKEYBYTES)
if (!sodium.cryptoScalarMultBase(sig_R, rHash)) return null
// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts))
val hRamData = sig_R + kA + message
val hRamHash = ByteArray(Hash.SHA512_BYTES)
if (!sodium.cryptoHashSha512(hRamHash, hRamData, hRamData.size.toLong())) return null
val hRam = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
sodium.cryptoCoreEd25519ScalarReduce(hRam, hRamHash)
if (hRam.all { it.toInt() == 0 }) {
return null
}
// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka))
val sig_sMul = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
val sig_s = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
if (sodium.cryptoScalarMult(sig_sMul, hRam, ka)) {
sodium.cryptoCoreEd25519ScalarReduce(sig_s/*, rHash*/, sig_sMul)
} else return null
return sig_R + sig_s
}
/* Combines two keys (`kA`) */
private fun combineKeys(lhsKey: ByteArray, rhsKey: ByteArray): ByteArray? {
return sodium.cryptoScalarMult(Key.fromBytes(lhsKey), Key.fromBytes(rhsKey)).asBytes
}
/*
Calculate a shared secret for a message from A to B:
BLAKE2b(a kB || kA || kB)
The receiver can calculate the same value via:
BLAKE2b(b kA || kA || kB)
*/
fun sharedBlindedEncryptionKey(
secretKey: ByteArray,
otherBlindedPublicKey: ByteArray,
kA: ByteArray, /*fromBlindedPublicKey*/
kB: ByteArray /*toBlindedPublicKey*/
): ByteArray? {
val aBytes = generatePrivateKeyScalar(secretKey) ?: return null
val combinedKeyBytes = combineKeys(aBytes, otherBlindedPublicKey) ?: return null
val outputHash = ByteArray(GenericHash.KEYBYTES)
val inputBytes = combinedKeyBytes + kA + kB
return if (sodium.cryptoGenericHash(outputHash, outputHash.size, inputBytes, inputBytes.size.toLong())) {
outputHash
} else null
}
/* This method should be used to check if a users standard sessionId matches a blinded one */
fun sessionId(
standardSessionId: String,
blindedSessionId: String,
serverPublicKey: String
): Boolean {
// Only support generating blinded keys for standard session ids
val sessionId = SessionId(standardSessionId)
if (sessionId.prefix != IdPrefix.STANDARD) return false
val blindedId = SessionId(blindedSessionId)
if (blindedId.prefix != IdPrefix.BLINDED) return false
val k = generateBlindingFactor(serverPublicKey) ?: return false
// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what
// Signal's XEd25519 conversion always uses)
val xEd25519Key = Key.fromHexString(sessionId.publicKey).asBytes
// Blind the positive public key
val pk1 = combineKeys(k, xEd25519Key) ?: return false
// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2
// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000])
val pk2 = pk1.take(31).toByteArray() + listOf(pk1.last().inv()).toByteArray()
return SessionId(IdPrefix.BLINDED, pk1).publicKey == blindedId.publicKey ||
SessionId(IdPrefix.BLINDED, pk2).publicKey == blindedId.publicKey
}
class SessionId {
var prefix: IdPrefix?
var publicKey: String
constructor(id: String) {
prefix = IdPrefix.fromValue(id.take(2))
publicKey = id.drop(2)
}
constructor(prefix: IdPrefix, publicKey: ByteArray) {
this.prefix = prefix
this.publicKey = publicKey.toHexString()
}
val hexString
get() = prefix?.prefix + publicKey
}
enum class IdPrefix(val prefix: String) {
STANDARD("05"), BLINDED("15"), UN_BLINDED("00");
companion object {
fun fromValue(prefix: String): IdPrefix? = when(prefix) {
STANDARD.prefix -> STANDARD
BLINDED.prefix -> BLINDED
UN_BLINDED.prefix -> UN_BLINDED
else -> null
}
}
}
}

View File

@ -441,7 +441,10 @@ 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? = null): Promise<Map<*, *>, Exception> {
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
val payload = mapOf(
"method" to method.rawValue,
"params" to parameters
)
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
val error = when (exception) {
is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)

View File

@ -530,7 +530,7 @@ object SnodeAPI {
400, 500, 502, 503 -> { // Usually indicates that the snode isn't up to date
handleBadSnode()
}
406 -> {
425 -> {
Log.d("Loki", "The user's clock is out of sync with the service node network.")
broadcaster.broadcast("clockOutOfSync")
return Error.ClockOutOfSync

View File

@ -1,5 +1,6 @@
rootProject.name = "session-android"
include ':app'
include ':liblazysodium'
include ':libsession'
include ':libsignal'