diff --git a/app/build.gradle b/app/build.gradle index 3504681917..c4944290a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -104,8 +104,8 @@ dependencies { implementation project(":libsignal") 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 "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" + implementation project(":liblazysodium") 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" diff --git a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt new file mode 100644 index 0000000000..af260a0bf0 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt @@ -0,0 +1,166 @@ +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.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.toHexString + +@RunWith(AndroidJUnit4::class) +class SodiumUtilitiesTest { + + private val publicKey: String = "88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" + private val privateKey: String = "30d796c1ddb4dc455fd998a98aa275c247494a9a7bde9c1fee86ae45cd585241" + private val edKeySeed: String = "c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9" + private val edPublicKey: String = "bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc" + private val edSecretKey: String = "c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc" + private val blindedPublicKey: String = "98932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5" + private val serverPublicKey: String = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d" + + private val edKeyPair = KeyPair(Key.fromHexString(edPublicKey), Key.fromHexString(edSecretKey)) + + @Test + fun generateBlindingFactorSuccess() { + val result = SodiumUtilities.generateBlindingFactor(serverPublicKey) + + assertThat(result?.toHexString(), equalTo("84e3eb75028a9b73fec031b7448e322a68ca6485fad81ab1bead56f759ebeb0f")) + } + + @Test + fun generateBlindingFactorFailure() { + val result = SodiumUtilities.generateBlindingFactor("Test") + + assertNull(result?.toHexString()) + } + + @Test + fun blindedKeyPairSuccess() { + val result = SodiumUtilities.blindedKeyPair(serverPublicKey, edKeyPair)!! + + assertThat(result.publicKey.asHexString.lowercase(), equalTo(blindedPublicKey)) + assertThat(result.secretKey.asHexString.take(64).lowercase(), equalTo("16663322d6b684e1c9dcc02b9e8642c3affd3bc431a9ea9e63dbbac88ce7a305")) + } + + @Test + fun blindedKeyPairFailurePublicKeyLength() { + val result = SodiumUtilities.blindedKeyPair( + serverPublicKey, + KeyPair(Key.fromHexString(edPublicKey.take(4)), Key.fromHexString(edKeySeed)) + ) + + assertNull(result) + } + + @Test + fun blindedKeyPairFailureSecretKeyLength() { + val result = SodiumUtilities.blindedKeyPair( + serverPublicKey, + KeyPair(Key.fromHexString(edPublicKey), Key.fromHexString(edSecretKey.take(4))) + ) + + assertNull(result) + } + + @Test + fun blindedKeyPairFailureBlindingFactor() { + val result = SodiumUtilities.blindedKeyPair("Test", edKeyPair) + + assertNull(result) + } + + @Test + fun sogsSignature() { + val expectedSignature = "dcc086abdd2a740d9260b008fb37e12aa0ff47bd2bd9e177bbbec37fd46705a9072ce747bda66c788c3775cdd7ad60ad15a478e0886779aad5d795fd7bf8350d" + + val result = SodiumUtilities.sogsSignature( + "TestMessage".toByteArray(), + Hex.fromStringCondensed(edSecretKey), + Hex.fromStringCondensed("44d82cc15c0a5056825cae7520b6b52d000a23eb0c5ed94c4be2d9dc41d2d409"), + Hex.fromStringCondensed("0bb7815abb6ba5142865895f3e5286c0527ba4d31dbb75c53ce95e91ffe025a2") + ) + + assertThat(result?.toHexString(), equalTo(expectedSignature)) + } + + @Test + fun combineKeysSuccess() { + val result = SodiumUtilities.combineKeys( + Hex.fromStringCondensed(edSecretKey), + Hex.fromStringCondensed(edPublicKey) + ) + + assertThat(result?.toHexString(), equalTo("1159b5d0fcfba21228eb2121a0f59712fa8276fc6e5547ff519685a40b9819e6")) + } + + @Test + fun combineKeysFailure() { + val result = SodiumUtilities.combineKeys( + SodiumUtilities.generatePrivateKeyScalar(Hex.fromStringCondensed(edSecretKey))!!, + Hex.fromStringCondensed(publicKey) + ) + + assertNull(result?.toHexString()) + } + + @Test + fun sharedBlindedEncryptionKeySuccess() { + val result = SodiumUtilities.sharedBlindedEncryptionKey( + Hex.fromStringCondensed(edSecretKey), + Hex.fromStringCondensed(blindedPublicKey), + Hex.fromStringCondensed(publicKey), + Hex.fromStringCondensed(blindedPublicKey) + ) + + assertThat(result?.toHexString(), equalTo("388ee09e4c356b91f1cce5cc0aa0cf59e8e8cade69af61685d09c2d2731bc99e")) + } + + @Test + fun sharedBlindedEncryptionKeyFailure() { + val result = SodiumUtilities.sharedBlindedEncryptionKey( + Hex.fromStringCondensed(edSecretKey), + Hex.fromStringCondensed(publicKey), + Hex.fromStringCondensed(edPublicKey), + Hex.fromStringCondensed(publicKey) + ) + + assertNull(result?.toHexString()) + } + + @Test + fun sessionIdSuccess() { + val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", serverPublicKey) + + assertTrue(result) + } + + @Test + fun sessionIdFailureInvalidSessionId() { + val result = SodiumUtilities.sessionId("AB$publicKey", "15$blindedPublicKey", serverPublicKey) + + assertFalse(result) + } + + @Test + fun sessionIdFailureInvalidBlindedId() { + val result = SodiumUtilities.sessionId("05$publicKey", "AB$blindedPublicKey", serverPublicKey) + + assertFalse(result) + } + + @Test + fun sessionIdFailureBlindingFactor() { + val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", "Test") + + assertFalse(result) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 6265f2d1f5..4797bb3edd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -15,6 +15,7 @@ import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ResourceContactPhoto import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests @@ -57,6 +58,11 @@ class ProfilePictureView @JvmOverloads constructor( val apk = members.getOrNull(1)?.serialize() ?: "" additionalPublicKey = apk additionalDisplayName = getUserDisplayName(apk) + } else if(recipient.isOpenGroupInboxRecipient) { + val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) + this.publicKey = publicKey + displayName = getUserDisplayName(publicKey) + additionalPublicKey = null } else { val publicKey = recipient.address.toString() this.publicKey = publicKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 047e65e48f..236c01c688 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -58,19 +58,22 @@ 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.OpenGroupApi 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 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsignal.crypto.MnemonicCodec +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey @@ -109,6 +112,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord @@ -167,6 +171,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase + @Inject lateinit var storage: Storage @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory private val screenWidth = Resources.getSystem().displayMetrics.widthPixels @@ -177,9 +182,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val viewModel: ConversationViewModel by viewModels { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { - intent.getParcelableExtra
(ADDRESS)?.let { address -> - val recipient = Recipient.from(this, address, false) - threadId = threadDb.getOrCreateThreadIdFor(recipient) + intent.getParcelableExtra
(ADDRESS)?.let { it -> + threadId = threadDb.getThreadIdIfExistsFor(it.serialize()) + if (threadId == -1L) { + val sessionId = SessionId(it.serialize()) + val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) + val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { + storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let { + fromSerialized(it) + } ?: run { + val openGroupInboxId = + "${openGroup.server}!${openGroup.publicKey}!${sessionId.hexString}".toByteArray() + fromSerialized(GroupUtil.getEncodedOpenGroupInboxID(openGroupInboxId)) + } + } else { + it + } + val recipient = Recipient.from(this, address, false) + threadId = threadDb.getOrCreateThreadIdFor(recipient) + } } ?: finish() } viewModelFactory.create(threadId) @@ -263,6 +284,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Extras const val THREAD_ID = "thread_id" const val ADDRESS = "address" + const val FROM_GROUP_THREAD_ID = "from_group_thread_id" const val SCROLL_MESSAGE_ID = "scroll_message_id" const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" // Request codes @@ -508,7 +530,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun getLatestOpenGroupInfoIfNeeded() { val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return - OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } + OpenGroupApi.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } } // called from onCreate diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index a72ee0daf8..834b77eccb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -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.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.GlideRequests class MentionCandidateView : LinearLayout { @@ -34,7 +34,7 @@ class MentionCandidateView : LinearLayout { profilePictureView.root.glide = glide!! profilePictureView.root.update() if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!) + val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE } else { moderatorIconImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 83cd2a250f..a21ba1b502 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -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.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.GlideRequests class MentionCandidateView : RelativeLayout { @@ -34,7 +34,7 @@ class MentionCandidateView : RelativeLayout { profilePictureView.root.glide = glide!! profilePictureView.root.update() if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!) + val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE } else { moderatorIconImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 275691c002..ffd3a41bfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -5,13 +5,17 @@ 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.MessagingModuleConfiguration +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.OpenGroupManager class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long, private val context: Context) : ActionMode.Callback { @@ -34,6 +38,9 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! + val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! + val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } + ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString fun userCanDeleteSelectedItems(): Boolean { val allSentByCurrentUser = selectedItems.all { it.isOutgoing } @@ -41,13 +48,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 OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) } 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 OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) } fun userCanBanSelectedUsers(): Boolean { if (openGroup == null) { return false } @@ -55,7 +62,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 OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) } // Delete message menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index a8527b09b1..be107c03d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.content.Intent import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect @@ -23,16 +24,19 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ViewUtil +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils @@ -140,15 +144,27 @@ class VisibleMessageView : LinearLayout { binding.profilePictureView.root.glide = glide binding.profilePictureView.root.update(message.individualRecipient) binding.profilePictureView.root.setOnClickListener { - showUserDetails(senderSessionID, threadID) + if (thread.isOpenGroupRecipient) { + if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) + intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) + context.startActivity(intent) + } + } else { + maybeShowUserDetails(senderSessionID, threadID) + } } if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return - val isModerator = OpenGroupAPIV2.isUserModerator( - senderSessionID, - openGroup.room, - openGroup.server - ) + var standardPublicKey = "" + var blindedPublicKey: String? = null + if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + blindedPublicKey = senderSessionID + } else { + standardPublicKey = senderSessionID + } + val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey) binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator } } @@ -403,7 +419,7 @@ class VisibleMessageView : LinearLayout { pressCallback = null } - private fun showUserDetails(publicKey: String, threadID: Long) { + private fun maybeShowUserDetails(publicKey: String, threadID: Long) { val userDetailsBottomSheet = UserDetailsBottomSheet() val bundle = bundleOf( UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 9dafdcf878..11b2c6f6d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -11,6 +11,7 @@ import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.UiModeUtilities @@ -20,39 +21,27 @@ object MentionUtilities { @JvmStatic fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { - val threadDB = DatabaseComponent.get(context).threadDatabase() - val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false - return highlightMentions(text, false, isOpenGroup, context).toString() // isOutgoingMessage is irrelevant - } - - @JvmStatic - fun highlightMentions(text:CharSequence, isOpenGroup: Boolean, context: Context): String { - return highlightMentions(text, false, isOpenGroup, context).toString() + return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant } @JvmStatic fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { - val threadDB = DatabaseComponent.get(context).threadDatabase() - val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false - return highlightMentions(text, isOutgoingMessage, isOpenGroup, context) // isOutgoingMessage is irrelevant - } - - @JvmStatic - fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, isOpenGroup: Boolean, context: Context): SpannableString { @Suppress("NAME_SHADOWING") var text = text val pattern = Pattern.compile("@[0-9a-fA-F]*") var matcher = pattern.matcher(text) val mentions = mutableListOf, String>>() var startIndex = 0 val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! + val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID) if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true)) { - TextSecurePreferences.getProfileName(context) + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false + val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { + context.getString(R.string.MessageRecord_you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) - @Suppress("NAME_SHADOWING") val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR + @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR contact?.displayName(context) } if (userDisplayName != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt new file mode 100644 index 0000000000..950c1c6bcf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import androidx.core.database.getStringOrNull +import org.session.libsession.messaging.BlindedIdMapping +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper + +class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { + + companion object { + const val TABLE_NAME = "blinded_id_mapping" + const val ROW_ID = "_id" + const val BLINDED_PK = "blinded_pk" + const val SESSION_PK = "session_pk" + const val SERVER_URL = "server_url" + const val SERVER_PK = "server_pk" + + @JvmField + val CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND = """ + CREATE TABLE $TABLE_NAME ( + $ROW_ID INTEGER PRIMARY KEY, + $BLINDED_PK TEXT NOT NULL, + $SESSION_PK TEXT DEFAULT NULL, + $SERVER_URL TEXT NOT NULL, + $SERVER_PK TEXT NOT NULL + ) + """.trimIndent() + + private fun readBlindedIdMapping(cursor: Cursor): BlindedIdMapping { + return BlindedIdMapping( + blindedId = cursor.getString(cursor.getColumnIndexOrThrow(BLINDED_PK)), + sessionId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)), + serverUrl = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_URL)), + serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_PK)), + ) + } + } + + fun getBlindedIdMapping(blindedId: String): List { + val query = "$BLINDED_PK = ?" + val args = arrayOf(blindedId) + + val mappings: MutableList = mutableListOf() + + readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + mappings += readBlindedIdMapping(cursor) + } + } + + return mappings + } + + fun addBlindedIdMapping(blindedIdMapping: BlindedIdMapping) { + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put(BLINDED_PK, blindedIdMapping.blindedId) + put(SERVER_PK, blindedIdMapping.sessionId) + put(SERVER_URL, blindedIdMapping.serverUrl) + put(SERVER_PK, blindedIdMapping.serverId) + } + + writableDatabase.insert(TABLE_NAME, null, values) + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + fun getBlindedIdMappingsExceptFor(server: String): List { + val query = "$SESSION_PK IS NOT NULL AND $SERVER_URL <> ?" + val args = arrayOf(server) + + val mappings: MutableList = mutableListOf() + + readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + mappings += readBlindedIdMapping(cursor) + } + } + + return mappings + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index a6db149053..b984a6e0f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -23,6 +23,8 @@ import android.database.Cursor; import androidx.annotation.NonNull; +import net.sqlcipher.database.SQLiteDatabase; + import org.session.libsession.utilities.WindowDebouncer; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -92,4 +94,12 @@ public abstract class Database { this.databaseHelper = databaseHelper; } + protected SQLiteDatabase getReadableDatabase() { + return databaseHelper.getReadableDatabase(); + } + + protected SQLiteDatabase getWritableDatabase() { + return databaseHelper.getWritableDatabase(); + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt new file mode 100644 index 0000000000..878b8a1c09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.session.libsession.messaging.open_groups.GroupMember +import org.session.libsession.messaging.open_groups.GroupMemberRole + +class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { + + companion object { + const val TABLE_NAME = "group_member" + const val GROUP_ID = "group_id" + const val PROFILE_ID = "profile_id" + const val ROLE = "role" + + private val allColumns = arrayOf(GROUP_ID, PROFILE_ID, ROLE) + + @JvmField + val CREATE_GROUP_MEMBER_TABLE_COMMAND = """ + CREATE TABLE $TABLE_NAME ( + $GROUP_ID TEXT NOT NULL, + $PROFILE_ID TEXT NOT NULL, + $ROLE TEXT NOT NULL, + PRIMARY KEY ($GROUP_ID, $PROFILE_ID) + ) + """.trimIndent() + + private fun readGroupMember(cursor: Cursor): GroupMember { + return GroupMember( + groupId = cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)), + profileId = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_ID)), + role = GroupMemberRole.valueOf(cursor.getString(cursor.getColumnIndexOrThrow(ROLE))), + ) + } + } + + fun getGroupMemberRoles(groupId: String, profileId: String): List { + val query = "$GROUP_ID = ? AND $PROFILE_ID = ?" + val args = arrayOf(groupId, profileId) + + val mappings: MutableList = mutableListOf() + + readableDatabase.query(TABLE_NAME, allColumns, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + mappings += readGroupMember(cursor) + } + } + + return mappings.map { it.role } + } + + fun addGroupMember(member: GroupMember) { + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put(GROUP_ID, member.groupId) + put(PROFILE_ID, member.profileId) + put(ROLE, member.role.name) + } + val query = "$GROUP_ID = ? AND $PROFILE_ID = ?" + val args = arrayOf(member.groupId, member.profileId) + + writableDatabase.insertOrUpdate(TABLE_NAME, values, query, args) + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 97cfc3e6e8..0e3d6f19db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -12,7 +12,7 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -127,6 +127,21 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( """ const val INSERT_RECEIVED_HASHES_DATA = "INSERT OR IGNORE INTO $receivedMessageHashValuesTable($publicKey, $receivedMessageHashValues) SELECT $publicKey, $receivedMessageHashValues FROM $legacyReceivedMessageHashValuesTable3;" const val DROP_LEGACY_RECEIVED_HASHES = "DROP TABLE $legacyReceivedMessageHashValuesTable3;" + // Open group server capabilities + private val serverCapabilitiesTable = "open_group_server_capabilities" + private val capabilities = "capabilities" + @JvmStatic + val createServerCapabilitiesCommand = "CREATE TABLE $serverCapabilitiesTable($server STRING PRIMARY KEY, $capabilities STRING)" + // Last inbox message server IDs + private val lastInboxMessageServerIdTable = "open_group_last_inbox_message_server_id_cache" + private val lastInboxMessageServerId = "last_inbox_message_server_id" + @JvmStatic + val createLastInboxMessageServerIdCommand = "CREATE TABLE $lastInboxMessageServerIdTable($server STRING PRIMARY KEY, $lastInboxMessageServerId INTEGER DEFAULT 0)" + // Last outbox message server IDs + private val lastOutboxMessageServerIdTable = "open_group_last_outbox_message_server_id_cache" + private val lastOutboxMessageServerId = "last_outbox_message_server_id" + @JvmStatic + val createLastOutboxMessageServerIdCommand = "CREATE TABLE $lastOutboxMessageServerIdTable($server STRING PRIMARY KEY, $lastOutboxMessageServerId INTEGER DEFAULT 0)" // region Deprecated private val deviceLinkCache = "loki_pairing_authorisation_cache" @@ -423,14 +438,14 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( override fun getUserX25519KeyPair(): ECKeyPair { val keyPair = IdentityKeyUtil.getIdentityKeyPair(context) - return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removing05PrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) + return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) } fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { val database = databaseHelper.writableDatabase val timestamp = Date().time.toString() val index = "$groupPublicKey-$timestamp" - val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removing05PrefixIfNeeded() + val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() val row = wrap(mapOf(closedGroupsEncryptionKeyPairIndex to index, Companion.encryptionKeyPairPublicKey to encryptionKeyPairPublicKey, Companion.encryptionKeyPairPrivateKey to encryptionKeyPairPrivateKey )) @@ -481,6 +496,53 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.delete(closedGroupPublicKeysTable, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) } + fun setServerCapabilities(serverName: String, serverCapabilities: List) { + val database = databaseHelper.writableDatabase + val row = wrap(mapOf(server to serverName, capabilities to serverCapabilities.joinToString(","))) + database.insertOrUpdate(serverCapabilitiesTable, row, "$server = ?", wrap(serverName)) + } + + fun getServerCapabilities(serverName: String): List { + val database = databaseHelper.writableDatabase + return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor -> + cursor.getString(capabilities) + }?.split(",") ?: emptyList() + } + + fun setLastInboxMessageId(serverName: String, newValue: Long) { + val database = databaseHelper.writableDatabase + val row = wrap(mapOf(server to serverName, lastInboxMessageServerId to newValue.toString())) + database.insertOrUpdate(lastInboxMessageServerIdTable, row, "$server = ?", wrap(serverName)) + } + + fun getLastInboxMessageId(serverName: String): Long? { + val database = databaseHelper.writableDatabase + return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> + cursor.getInt(lastInboxMessageServerId) + }?.toLong() + } + + fun removeLastInboxMessageId(serverName: String) { + databaseHelper.writableDatabase.delete(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) + } + + fun setLastOutboxMessageId(serverName: String, newValue: Long) { + val database = databaseHelper.writableDatabase + val row = wrap(mapOf(server to serverName, lastOutboxMessageServerId to newValue.toString())) + database.insertOrUpdate(lastOutboxMessageServerIdTable, row, "$server = ?", wrap(serverName)) + } + + fun getLastOutboxMessageId(serverName: String): Long? { + val database = databaseHelper.writableDatabase + return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> + cursor.getInt(lastOutboxMessageServerId) + }?.toLong() + } + + fun removeLastOutboxMessageId(serverName: String) { + databaseHelper.writableDatabase.delete(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) + } + override fun getForkInfo(): ForkInfo { val database = databaseHelper.readableDatabase val queryCursor = database.query(FORK_INFO_TABLE, arrayOf(HF_VALUE, SF_VALUE), "$DUMMY_KEY = $DUMMY_VALUE", null, null, null, null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt index 16c4750818..300217faba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.JsonUtil @@ -30,16 +30,16 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) } - fun getAllV2OpenGroups(): Map { + fun getAllOpenGroups(): Map { val database = databaseHelper.readableDatabase var cursor: Cursor? = null - val result = mutableMapOf() + val result = mutableMapOf() try { cursor = database.rawQuery("select * from $publicChatTable", null) while (cursor != null && cursor.moveToNext()) { val threadID = cursor.getLong(threadID) val string = cursor.getString(publicChat) - val openGroup = OpenGroupV2.fromJSON(string) + val openGroup = OpenGroup.fromJSON(string) if (openGroup != null) result[threadID] = openGroup } } catch (e: Exception) { @@ -50,25 +50,25 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa return result } - fun getOpenGroupChat(threadID: Long): OpenGroupV2? { + fun getOpenGroupChat(threadID: Long): OpenGroup? { if (threadID < 0) { return null } val database = databaseHelper.readableDatabase return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor -> val json = cursor.getString(publicChat) - OpenGroupV2.fromJSON(json) + OpenGroup.fromJSON(json) } } - fun setOpenGroupChat(openGroupV2: OpenGroupV2, threadID: Long) { + fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) { if (threadID < 0) { return } val database = databaseHelper.writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.threadID, threadID) - contentValues.put(publicChat, JsonUtil.toJson(openGroupV2.toJson())) + contentValues.put(publicChat, JsonUtil.toJson(openGroup.toJson())) database.insertOrUpdate(publicChatTable, contentValues, "${Companion.threadID} = ?", arrayOf(threadID.toString())) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index a3906d2871..42e41a191b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -42,6 +42,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn public abstract boolean deleteMessage(long messageId); + public abstract void updateThreadId(long fromId, long toId); + public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { try { addToDocument(messageId, MISMATCHED_IDENTITIES, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index fdb9d1c161..46db0fc1d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -1031,6 +1031,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return threadDeleted } + override fun updateThreadId(fromId: Long, toId: Long) { + val contentValues = ContentValues(1) + contentValues.put(THREAD_ID, toId) + + val db = databaseHelper.writableDatabase + db.update(SmsDatabase.TABLE_NAME, contentValues, "$THREAD_ID = ?", arrayOf("$fromId")) + notifyConversationListeners(toId) + notifyConversationListListeners() + } + fun deleteThread(threadId: Long) { deleteThreads(setOf(threadId)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index a0d8715fac..cf8ed7097d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -93,12 +93,11 @@ public class MmsSmsDatabase extends Database { MmsSmsDatabase.Reader reader = readerFor(cursor); MessageRecord messageRecord; + boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); while ((messageRecord = reader.getNext()) != null) { - if ((Util.isOwnNumber(context, serializedAuthor) && messageRecord.isOutgoing()) || - (!Util.isOwnNumber(context, serializedAuthor) - && messageRecord.getIndividualRecipient().getAddress().serialize().equals(serializedAuthor) - )) + if ((isOwnNumber && messageRecord.isOutgoing()) || + (!isOwnNumber && messageRecord.getIndividualRecipient().getAddress().serialize().equals(serializedAuthor))) { return messageRecord; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 9f3284275b..9639ed0a4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -255,10 +255,6 @@ public class RecipientDatabase extends Database { recipient.resolve().setApproved(approved); } - public void setAllApproved(List addresses) { - - } - public void setApprovedMe(@NonNull Recipient recipient, boolean approvedMe) { ContentValues values = new ContentValues(); values.put(APPROVED_ME, approvedMe ? 1 : 0); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 3e8f37cc36..6fe2eda22f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -575,6 +575,17 @@ public class SmsDatabase extends MessagingDatabase { return threadDeleted; } + @Override + public void updateThreadId(long fromId, long toId) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(MmsSmsColumns.THREAD_ID, toId); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, THREAD_ID + " = ?", new String[] {fromId + ""}); + notifyConversationListeners(toId); + notifyConversationListListeners(); + } + private boolean isDuplicate(IncomingTextMessage message, long threadId) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", 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 5762233678..88f9409838 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob @@ -22,12 +23,15 @@ import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.open_groups.GroupMember +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.Address @@ -40,6 +44,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.ApplicationContext @@ -73,7 +78,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } override fun setUserProfilePictureURL(newValue: String) { - val ourRecipient = Address.fromSerialized(getUserPublicKey()!!).let { + val ourRecipient = fromSerialized(getUserPublicKey()!!).let { Recipient.from(context, it, false) } TextSecurePreferences.setProfilePictureURL(context, newValue) @@ -125,8 +130,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, runIncrement: Boolean, runThreadUpdate: Boolean): Long? { var messageID: Long? = null - val senderAddress = Address.fromSerialized(message.sender!!) + val senderAddress = fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) + val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let { getOpenGroup(it)?.publicKey } + ?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) groupPublicKey != null -> { @@ -138,17 +145,17 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val pointers = attachments.mapNotNull { it.toSignalAttachment() } - val targetAddress = if (isUserSender && !message.syncTarget.isNullOrEmpty()) { - Address.fromSerialized(message.syncTarget!!) + val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) { + fromSerialized(message.syncTarget!!) } else if (group.isPresent) { - Address.fromSerialized(GroupUtil.getEncodedId(group.get())) + fromSerialized(GroupUtil.getEncodedId(group.get())) } else { senderAddress } val targetRecipient = Recipient.from(context, targetAddress, false) if (!targetRecipient.isGroupRecipient) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() - if (isUserSender) { + if (isUserSender || isUserBlindedSender) { recipientDb.setApproved(targetRecipient, true) } else { recipientDb.setApprovedMe(targetRecipient, true) @@ -158,7 +165,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - val insertResult = if (message.sender == getUserPublicKey()) { + val insertResult = if (isUserSender || isUserBlindedSender) { val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { @@ -176,7 +183,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val smsDatabase = DatabaseComponent.get(context).smsDatabase() val isOpenGroupInvitation = (message.openGroupInvitation != null) - val insertResult = if (message.sender == getUserPublicKey()) { + val insertResult = if (isUserSender || isUserBlindedSender) { val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp) else OutgoingTextMessage.from(message, targetRecipient) smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) @@ -259,12 +266,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, null) } - override fun getV2OpenGroup(threadId: Long): OpenGroupV2? { + override fun getOpenGroup(threadId: Long): OpenGroup? { if (threadId.toInt() < 0) { return null } val database = databaseHelper.readableDatabase return database.get(LokiThreadDatabase.publicChatTable, "${LokiThreadDatabase.threadID} = ?", arrayOf( threadId.toString() )) { cursor -> val publicChatAsJson = cursor.getString(LokiThreadDatabase.publicChat) - OpenGroupV2.fromJSON(publicChatAsJson) + OpenGroup.fromJSON(publicChatAsJson) } } @@ -309,6 +316,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).lokiMessageDatabase().setOriginalThreadID(messageID, serverID, threadID) } + override fun getOpenGroup(room: String, server: String): OpenGroup? { + return getAllOpenGroups().values.firstOrNull { it.server == server && it.room == room } + } + + override fun addGroupMember(member: GroupMember) { + DatabaseComponent.get(context).groupMemberDatabase().addGroupMember(member) + } + override fun isDuplicateMessage(timestamp: Long): Boolean { return getReceivedMessageTimestamps().contains(timestamp) } @@ -335,7 +350,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val address = Address.fromSerialized(author) + val address = fromSerialized(author) return database.getMessageFor(timestamp, address)?.getId() } @@ -453,7 +468,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) { val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) - val m = IncomingTextMessage(Address.fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() @@ -462,7 +477,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { val userPublicKey = getUserPublicKey() - val recipient = Recipient.from(context, Address.fromSerialized(groupID), false) + val recipient = Recipient.from(context, fromSerialized(groupID), false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf()) @@ -475,7 +490,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun isClosedGroup(publicKey: String): Boolean { val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) - val address = Address.fromSerialized(publicKey) + val address = fromSerialized(publicKey) return address.isClosedGroup || isClosedGroup } @@ -528,8 +543,20 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); } - override fun getAllV2OpenGroups(): Map { - return DatabaseComponent.get(context).lokiThreadDatabase().getAllV2OpenGroups() + override fun setServerCapabilities(server: String, capabilities: List) { + return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) + } + + override fun getServerCapabilities(server: String): List { + return DatabaseComponent.get(context).lokiAPIDatabase().getServerCapabilities(server) + } + + override fun getAllOpenGroups(): Map { + return DatabaseComponent.get(context).lokiThreadDatabase().getAllOpenGroups() + } + + override fun updateOpenGroup(openGroup: OpenGroup) { + OpenGroupManager.updateOpenGroup(openGroup, context) } override fun getAllGroups(): List { @@ -541,7 +568,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } override fun onOpenGroupAdded(urlAsString: String) { - val server = OpenGroupV2.getServer(urlAsString) + val server = OpenGroup.getServer(urlAsString) OpenGroupManager.restartPollerForServer(server.toString().removeSuffix("/")) } @@ -562,20 +589,20 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long { val database = DatabaseComponent.get(context).threadDatabase() - if (!openGroupID.isNullOrEmpty()) { - val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) - return database.getThreadIdIfExistsFor(recipient) + return if (!openGroupID.isNullOrEmpty()) { + val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) + database.getThreadIdIfExistsFor(recipient) } else if (!groupPublicKey.isNullOrEmpty()) { - val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) - return database.getOrCreateThreadIdFor(recipient) + val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) + database.getOrCreateThreadIdFor(recipient) } else { - val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) - return database.getOrCreateThreadIdFor(recipient) + val recipient = Recipient.from(context, fromSerialized(publicKey), false) + database.getOrCreateThreadIdFor(recipient) } } override fun getThreadId(publicKeyOrOpenGroupID: String): Long? { - val address = Address.fromSerialized(publicKeyOrOpenGroupID) + val address = fromSerialized(publicKeyOrOpenGroupID) return getThreadId(address) } @@ -622,8 +649,13 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun addContacts(contacts: List) { val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase() - for (contact in contacts) { - val address = Address.fromSerialized(contact.publicKey) + val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val moreContacts = contacts.filter { contact -> + val id = SessionId(contact.publicKey) + id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null } + } + for (contact in moreContacts) { + val address = fromSerialized(contact.publicKey) val recipient = Recipient.from(context, address, true) if (!contact.profilePicture.isNullOrEmpty()) { recipientDatabase.setProfileAvatar(recipient, contact.profilePicture) @@ -715,12 +747,48 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, threadDB.setHasSent(threadId, true) } else { val mmsDb = DatabaseComponent.get(context).mmsDatabase() - val senderAddress = fromSerialized(senderPublicKey) - val requestSender = Recipient.from(context, senderAddress, false) - recipientDb.setApprovedMe(requestSender, true) + val smsDb = DatabaseComponent.get(context).smsDatabase() + val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) + val threadId = threadDB.getOrCreateThreadIdFor(sender) + threadDB.setHasSent(threadId, true) + val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val mappings = mutableMapOf() + threadDB.readerFor(threadDB.conversationList).use { reader -> + while (reader.next != null) { + val recipient = reader.current.recipient + val address = recipient.address.serialize() + val blindedId = when { + recipient.isGroupRecipient -> null + recipient.isOpenGroupInboxRecipient -> { + GroupUtil.getDecodedOpenGroupInbox(address) + } + else -> { + if (SessionId(address).prefix == IdPrefix.BLINDED) { + address + } else null + } + } ?: continue + mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let { + mappings[address] = it + } + } + } + for (mapping in mappings) { + if (!SodiumUtilities.sessionId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { + continue + } + mappingDb.addBlindedIdMapping(mapping.value.copy(sessionId = senderPublicKey)) + + val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) + mmsDb.updateThreadId(blindedThreadId, threadId) + smsDb.updateThreadId(blindedThreadId, threadId) + threadDB.deleteConversation(blindedThreadId) + } + recipientDb.setApproved(sender, true) + recipientDb.setApprovedMe(sender, true) val message = IncomingMediaMessage( - senderAddress, + sender.address, response.sentTimestamp!!, -1, 0, @@ -735,7 +803,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.absent(), Optional.absent() ) - val threadId = getOrCreateThreadIdFor(senderAddress) mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) } } @@ -748,7 +815,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) } - override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { val database = DatabaseComponent.get(context).smsDatabase() val address = fromSerialized(senderPublicKey) @@ -764,4 +830,62 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getLastSeenAndHasSent(threadId).second() ?: false } + + override fun getLastInboxMessageId(server: String): Long? { + return DatabaseComponent.get(context).lokiAPIDatabase().getLastInboxMessageId(server) + } + + override fun setLastInboxMessageId(server: String, messageId: Long) { + DatabaseComponent.get(context).lokiAPIDatabase().setLastInboxMessageId(server, messageId) + } + + override fun removeLastInboxMessageId(server: String) { + DatabaseComponent.get(context).lokiAPIDatabase().removeLastInboxMessageId(server) + } + + override fun getLastOutboxMessageId(server: String): Long? { + return DatabaseComponent.get(context).lokiAPIDatabase().getLastOutboxMessageId(server) + } + + override fun setLastOutboxMessageId(server: String, messageId: Long) { + DatabaseComponent.get(context).lokiAPIDatabase().setLastOutboxMessageId(server, messageId) + } + + override fun removeLastOutboxMessageId(server: String) { + DatabaseComponent.get(context).lokiAPIDatabase().removeLastOutboxMessageId(server) + } + + override fun getOrCreateBlindedIdMapping( + blindedId: String, + server: String, + serverPublicKey: String, + fromOutbox: Boolean + ): BlindedIdMapping { + val db = DatabaseComponent.get(context).blindedIdMappingDatabase() + val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey) + if (mapping.sessionId != null) { + return mapping + } + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.readerFor(threadDb.conversationList).use { reader -> + while (reader.next != null) { + val recipient = reader.current.recipient + val sessionId = recipient.address.serialize() + if (!recipient.isGroupRecipient && SodiumUtilities.sessionId(sessionId, blindedId, serverPublicKey)) { + val contactMapping = mapping.copy(sessionId = sessionId) + db.addBlindedIdMapping(contactMapping) + return contactMapping + } + } + } + db.getBlindedIdMappingsExceptFor(server).forEach { + if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) { + val otherMapping = mapping.copy(sessionId = it.sessionId) + db.addBlindedIdMapping(otherMapping) + return otherMapping + } + } + db.addBlindedIdMapping(mapping) + return mapping + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index feaa70dd7f..a64051ea27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -43,6 +43,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; +import org.session.libsignal.utilities.IdPrefix; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.guava.Optional; @@ -447,6 +448,11 @@ public class ThreadDatabase extends Database { return getConversationList(where); } + public Cursor getBlindedConversationList() { + String where = TABLE_NAME + "." + ADDRESS + " LIKE '" + IdPrefix.BLINDED.getValue() + "%' "; + return getConversationList(where); + } + public Cursor getApprovedConversationList() { String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 460b346b7e..0cf96119db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -14,8 +14,10 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase; @@ -67,9 +69,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV32 = 53; private static final int lokiV33 = 54; private static final int lokiV34 = 55; + private static final int lokiV35 = 55; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV34; + private static final int DATABASE_VERSION = lokiV35; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -135,6 +138,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand()); db.execSQL(LokiAPIDatabase.getCreateClosedGroupEncryptionKeyPairsTable()); db.execSQL(LokiAPIDatabase.getCreateClosedGroupPublicKeysTable()); + db.execSQL(LokiAPIDatabase.getCreateServerCapabilitiesCommand()); + db.execSQL(LokiAPIDatabase.getCreateLastInboxMessageServerIdCommand()); + db.execSQL(LokiAPIDatabase.getCreateLastOutboxMessageServerIdCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); @@ -161,6 +167,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.DROP_LEGACY_LAST_HASH); db.execSQL(LokiAPIDatabase.INSERT_RECEIVED_HASHES_DATA); db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES); + db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND); + db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); @@ -369,6 +377,14 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES); } + if (oldVersion < lokiV35) { + db.execSQL(LokiAPIDatabase.getCreateServerCapabilitiesCommand()); + db.execSQL(LokiAPIDatabase.getCreateLastInboxMessageServerIdCommand()); + db.execSQL(LokiAPIDatabase.getCreateLastOutboxMessageServerIdCommand()); + db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND); + db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 03022b69cc..372f142f99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -115,6 +115,8 @@ public class ThreadRecord extends DisplayRecord { return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified)); } else if (SmsDatabase.Types.isIdentityDefault(type)) { return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified)); + } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { + return emphasisAdded(context.getString(R.string.message_requests_accepted)); } else if (getCount() == 0) { return new SpannableString(context.getString(R.string.ThreadRecord_empty_message)); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index 382b0f4b74..4b1d346cbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -42,4 +42,6 @@ interface DatabaseComponent { fun sessionContactDatabase(): SessionContactDatabase fun storage(): Storage fun attachmentProvider(): MessageDataProvider + fun blindedIdMappingDatabase(): BlindedIdMappingDatabase + fun groupMemberDatabase(): GroupMemberDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 0a1171e504..a6afdc75f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -117,6 +117,14 @@ object DatabaseModule { @Singleton fun provideSessionContactDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SessionContactDatabase(context,openHelper) + @Provides + @Singleton + fun provideBlindedIdMappingDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = BlindedIdMappingDatabase(context, openHelper) + + @Provides + @Singleton + fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupMemberDatabase(context, openHelper) + @Provides @Singleton fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt index a9b6662d8b..c93b1e6026 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt @@ -4,19 +4,22 @@ 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 nl.komponents.kovenant.functional.map +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.thoughtcrime.securesms.util.State -typealias DefaultGroups = List +typealias DefaultGroups = List typealias GroupState = State class DefaultGroupsViewModel : ViewModel() { init { - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + OpenGroupApi.getDefaultServerCapabilities().map { + OpenGroupApi.getDefaultRoomsIfNeeded() + } } - val defaultRooms = OpenGroupAPIV2.defaultRooms.map { + val defaultRooms = OpenGroupApi.defaultRooms.map { State.Success(it) }.onStart { emit(State.Loading) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt index e464d69a0e..c2d37ba2e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt @@ -26,7 +26,7 @@ import network.loki.messenger.databinding.ActivityJoinPublicChatBinding import network.loki.messenger.databinding.FragmentEnterChatUrlBinding import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi.DefaultGroup import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 90f287cb1c..82ba43ab15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -4,16 +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.OpenGroupV2 -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2 +import org.session.libsession.messaging.open_groups.GroupMemberRole +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.concurrent.Executors object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) - private var pollers = mutableMapOf() // One for each server + private var pollers = mutableMapOf() // One for each server private var isPolling = false private val pollUpdaterLock = Any() @@ -38,10 +39,10 @@ object OpenGroupManager { if (isPolling) { return } isPolling = true val storage = MessagingModuleConfiguration.shared.storage - val servers = storage.getAllV2OpenGroups().values.map { it.server }.toSet() + val servers = storage.getAllOpenGroups().values.map { it.server }.toSet() servers.forEach { server -> pollers[server]?.stop() // Shouldn't be necessary - val poller = OpenGroupPollerV2(server, executorService) + val poller = OpenGroupPoller(server, executorService) poller.startIfNeeded() pollers[server] = poller } @@ -67,17 +68,21 @@ object OpenGroupManager { // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) + storage.removeLastInboxMessageId(server) + storage.removeLastOutboxMessageId(server) // Store the public key storage.setOpenGroupPublicKey(server,publicKey) - // Get group info - OpenGroupAPIV2.getAuthToken(room, server).get() - // Get group info - val info = OpenGroupAPIV2.getInfo(room, server).get() + // Get capabilities + val capabilities = OpenGroupApi.getCapabilities(server).get() + storage.setServerCapabilities(server, capabilities.capabilities) + // Get room info + val info = OpenGroupApi.getRoomInfo(room, server).get() + storage.setUserCount(room, server, info.activeUsers) // 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 = OpenGroup(server, room, info.name, info.infoUpdates, publicKey) threadDB.setOpenGroupChat(openGroup, threadID) } @@ -86,7 +91,7 @@ object OpenGroupManager { synchronized(pollUpdaterLock) { pollers[server]?.stop() pollers[server]?.startIfNeeded() ?: run { - val poller = OpenGroupPollerV2(server, executorService) + val poller = OpenGroupPoller(server, executorService) pollers[server] = poller poller.startIfNeeded() } @@ -102,7 +107,7 @@ object OpenGroupManager { threadDB.setThreadArchived(threadID) val groupID = recipient.address.serialize() // Stop the poller if needed - val openGroups = storage.getAllV2OpenGroups().filter { it.value.server == server } + val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } if (openGroups.count() == 1) { synchronized(pollUpdaterLock) { val poller = pollers[server] @@ -113,6 +118,8 @@ object OpenGroupManager { // Delete storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) + storage.removeLastInboxMessageId(server) + storage.removeLastOutboxMessageId(server) val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) ThreadUtils.queue { @@ -123,9 +130,26 @@ object OpenGroupManager { fun addOpenGroup(urlAsString: String, context: Context) { val url = HttpUrl.parse(urlAsString) ?: return - val server = OpenGroupV2.getServer(urlAsString) + val server = OpenGroup.getServer(urlAsString) val room = url.pathSegments().firstOrNull() ?: return val publicKey = url.queryParameter("public_key") ?: return add(server.toString().removeSuffix("/"), room, publicKey, context) } + + fun updateOpenGroup(openGroup: OpenGroup, context: Context) { + val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() + val openGroupID = "${openGroup.server}.${openGroup.room}" + val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) + threadDB.setOpenGroupChat(openGroup, threadID) + } + + fun isUserModerator(context: Context, groupId: String, standardPublicKey: String, blindedPublicKey: String? = null): Boolean { + val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() + val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) + val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() + + return GroupMemberRole.ADMIN in standardRoles || GroupMemberRole.MODERATOR in standardRoles || + GroupMemberRole.ADMIN in blindedRoles || GroupMemberRole.MODERATOR in blindedRoles + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt index ea4dc038a7..f2147aaf37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.groups 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.OpenGroupApi import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -29,8 +29,7 @@ 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 = OpenGroupApi.getRoomInfo(room, server).get() // store info again? EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 261890ba59..433d17f675 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -82,7 +82,7 @@ class ConversationView : LinearLayout { } binding.muteIndicatorImageView.setImageResource(drawableRes) val rawSnippet = thread.getDisplayBody(context) - val snippet = highlightMentions(rawSnippet, recipient.isOpenGroupRecipient, context) + val snippet = highlightMentions(rawSnippet, thread.threadId, context) binding.snippetTextView.text = snippet binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index 3f7e997b54..37e4a90dd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -21,6 +21,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -83,8 +84,8 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { } nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient + publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient + messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED publicKeyTextView.text = publicKey publicKeyTextView.setOnLongClickListener { val clipboard = @@ -103,6 +104,7 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { ) intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1) + intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) startActivity(intent) dismiss() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index f71050aba9..179c28bc3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.mms; import android.content.Context; -import org.session.libsession.messaging.file_server.FileServerAPIV2; +import org.session.libsession.messaging.file_server.FileServerApi; public class PushMediaConstraints extends MediaConstraints { @@ -21,26 +21,26 @@ public class PushMediaConstraints extends MediaConstraints { @Override public int getImageMaxSize(Context context) { - return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); + return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); } @Override public int getGifMaxSize(Context context) { - return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); + return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); } @Override public int getVideoMaxSize(Context context) { - return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); + return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); } @Override public int getAudioMaxSize(Context context) { - return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); + return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); } @Override public int getDocumentMaxSize(Context context) { - return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); + return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 95760a093d..2405a305f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -17,7 +17,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters 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.OpenGroupPoller import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log @@ -72,13 +72,13 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor // Open Groups val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() - val v2OpenGroups = threadDB.getAllV2OpenGroups() - val v2OpenGroupServers = v2OpenGroups.map { it.value.server }.toSet() + val openGroups = threadDB.getAllOpenGroups() + val openGroupServers = openGroups.map { it.value.server }.toSet() - for (server in v2OpenGroupServers) { - val poller = OpenGroupPollerV2(server, null) + for (server in openGroupServers) { + val poller = OpenGroupPoller(server, null) poller.hasStarted = true - promises.add(poller.poll(true)) + promises.add(poller.poll()) } // Wait until all the promises are resolved diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 63709a0e9e..e0645a588c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -36,15 +36,22 @@ import android.service.notification.StatusBarNotification; import android.text.TextUtils; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import com.goterl.lazysodium.utils.KeyPair; + +import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; +import org.session.libsession.messaging.utilities.SessionId; +import org.session.libsession.messaging.utilities.SodiumUtilities; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.IdPrefix; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Util; import org.thoughtcrime.securesms.ApplicationContext; @@ -52,6 +59,8 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; +import org.thoughtcrime.securesms.crypto.KeyPairUtilities; +import org.thoughtcrime.securesms.database.LokiThreadDatabase; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; @@ -66,9 +75,11 @@ import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SpanUtil; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.ListIterator; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.Executor; @@ -491,6 +502,7 @@ public class DefaultMessageNotifier implements MessageNotifier { MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor); ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); MessageRecord record; + Map cache = new HashMap(); while ((record = reader.getNext()) != null) { long id = record.getId(); @@ -534,16 +546,22 @@ public class DefaultMessageNotifier implements MessageNotifier { if (threadRecipients == null || !threadRecipients.isMuted()) { if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { + String userPublicKey = TextSecurePreferences.getLocalNumber(context); + String blindedPublicKey = cache.get(threadId); + if (blindedPublicKey == null) { + blindedPublicKey = generateBlindedId(threadId, context); + cache.put(threadId, blindedPublicKey); + } // check if mentioned here boolean isQuoteMentioned = false; if (record instanceof MmsMessageRecord) { Quote quote = ((MmsMessageRecord) record).getQuote(); Address quoteAddress = quote != null ? quote.getAuthor() : null; String serializedAddress = quoteAddress != null ? quoteAddress.serialize() : null; - isQuoteMentioned = serializedAddress != null && Objects.equals(TextSecurePreferences.getLocalNumber(context), serializedAddress); + isQuoteMentioned = (serializedAddress!= null && Objects.equals(userPublicKey, serializedAddress)) || + (blindedPublicKey != null && Objects.equals(userPublicKey, blindedPublicKey)); } - if (body.toString().contains("@"+TextSecurePreferences.getLocalNumber(context)) - || isQuoteMentioned) { + if (body.toString().contains("@"+userPublicKey) || body.toString().contains("@"+blindedPublicKey) || isQuoteMentioned) { notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); } } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { @@ -558,6 +576,19 @@ public class DefaultMessageNotifier implements MessageNotifier { return notificationState; } + private @Nullable String generateBlindedId(long threadId, Context context) { + LokiThreadDatabase lokiThreadDatabase = DatabaseComponent.get(context).lokiThreadDatabase(); + OpenGroup openGroup = lokiThreadDatabase.getOpenGroupChat(threadId); + KeyPair edKeyPair = KeyPairUtilities.INSTANCE.getUserED25519KeyPair(context); + if (openGroup != null && edKeyPair != null) { + KeyPair blindedKeyPair = SodiumUtilities.INSTANCE.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); + if (blindedKeyPair != null) { + return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); + } + } + return null; + } + private void updateBadge(Context context, int count) { try { if (count == 0) ShortcutBadger.removeCount(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt index 48a649dee3..adaec0e17a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.notifications import android.content.Context +import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.Version import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log @@ -43,7 +45,7 @@ object LokiPushNotificationManager { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> + getResponseBody(request.build()).map { json -> val code = json["code"] as? Int if (code != null && code != 0) { TextSecurePreferences.setIsUsingFCM(context, false) @@ -72,7 +74,7 @@ object LokiPushNotificationManager { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> + getResponseBody(request.build()).map { json -> val code = json["code"] as? Int if (code != null && code != 0) { TextSecurePreferences.setIsUsingFCM(context, true) @@ -100,7 +102,7 @@ object LokiPushNotificationManager { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> + getResponseBody(request.build()).map { json -> val code = json["code"] as? Int if (code == null || code == 0) { Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.") @@ -110,4 +112,10 @@ object LokiPushNotificationManager { } } } + + private fun getResponseBody(request: Request): Promise, Exception> { + return OnionRequestAPI.sendOnionRequest(request, server, pnServerPublicKey, Version.V2).map { response -> + JsonUtil.fromJson(response.body, Map::class.java) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 60b931ca04..97950165b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -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.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -86,7 +86,7 @@ class DefaultConversationRepository @Inject constructor( override fun isOxenHostedOpenGroup(threadId: Long): Boolean { val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - return openGroup?.publicKey == OpenGroupAPIV2.defaultServerPublicKey + return openGroup?.publicKey == OpenGroupApi.defaultServerPublicKey } override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { @@ -153,7 +153,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) + OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) continuation.resume(ResultOf.Success(Unit)) @@ -205,7 +205,7 @@ class DefaultConversationRepository @Inject constructor( messageServerIDs[messageServerID] = message } for ((messageServerID, message) in messageServerIDs) { - OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) }.fail { error -> @@ -228,7 +228,7 @@ class DefaultConversationRepository @Inject constructor( suspendCoroutine { continuation -> val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.ban(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> @@ -240,7 +240,7 @@ class DefaultConversationRepository @Inject constructor( suspendCoroutine { continuation -> val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt index b55f82eae7..bdccc33b96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt @@ -12,6 +12,7 @@ import android.graphics.drawable.BitmapDrawable import android.text.TextPaint import android.text.TextUtils import network.loki.messenger.R +import org.session.libsignal.utilities.IdPrefix import java.math.BigInteger import java.security.MessageDigest import java.util.Locale @@ -66,7 +67,7 @@ object AvatarPlaceholderGenerator { fun extractLabel(content: String): String { val trimmedContent = content.trim() if (trimmedContent.isEmpty()) return EMPTY_LABEL - return if (trimmedContent.length > 2 && trimmedContent.startsWith("05")) { + return if (trimmedContent.length > 2 && IdPrefix.fromValue(trimmedContent) != null) { trimmedContent[2].toString() } else { val splitWords = trimmedContent.split(Regex("\\W")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt index 8b0ab5d345..a5822b585f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt @@ -20,4 +20,5 @@ object ContactUtilities { } return result } + } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 021b5d4f74..d8668cfa1d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ lifecycleVersion=2.3.1 daggerVersion=2.40.1 glideVersion=4.11.0 kovenantVersion=3.3.0 -curve25519Version=0.5.0 +curve25519Version=0.6.0 protobufVersion=2.5.0 okhttpVersion=3.12.1 jacksonDatabindVersion=2.9.8 diff --git a/liblazysodium/build.gradle b/liblazysodium/build.gradle new file mode 100644 index 0000000000..10f52943d8 --- /dev/null +++ b/liblazysodium/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('lazysodium.aar')) \ No newline at end of file diff --git a/liblazysodium/lazysodium.aar b/liblazysodium/lazysodium.aar new file mode 100644 index 0000000000..4227d956e6 Binary files /dev/null and b/liblazysodium/lazysodium.aar differ diff --git a/libsession/build.gradle b/libsession/build.gradle index 044a4b63c5..0515b3562c 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -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' @@ -36,7 +37,7 @@ dependencies { implementation 'com.esotericsoftware:kryo:5.1.1' implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "org.whispersystems:curve25519-java:$curve25519Version" + implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" 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 8f04dfdf09..7be4c36805 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -11,12 +11,14 @@ import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.BlindedIdMapping +import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient @@ -54,13 +56,20 @@ interface StorageProtocol { fun setAuthToken(room: String, server: String, newValue: String) fun removeAuthToken(room: String, server: String) + // Servers + fun setServerCapabilities(server: String, capabilities: List) + fun getServerCapabilities(server: String): List + // Open Groups - fun getAllV2OpenGroups(): Map - fun getV2OpenGroup(threadId: Long): OpenGroupV2? + fun getAllOpenGroups(): Map + fun updateOpenGroup(openGroup: OpenGroup) + fun getOpenGroup(threadId: Long): OpenGroup? fun addOpenGroup(urlAsString: String) fun onOpenGroupAdded(urlAsString: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) + fun getOpenGroup(room: String, server: String): OpenGroup? + fun addGroupMember(member: GroupMember) // Open Group Public Keys fun getOpenGroupPublicKey(server: String): String? @@ -167,4 +176,16 @@ interface StorageProtocol { fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) fun conversationHasOutgoing(userPublicKey: String): Boolean + + // Last Inbox Message Id + fun getLastInboxMessageId(server: String): Long? + fun setLastInboxMessageId(server: String, messageId: Long) + fun removeLastInboxMessageId(server: String) + + // Last Outbox Message Id + fun getLastOutboxMessageId(server: String): Long? + fun setLastOutboxMessageId(server: String, messageId: Long) + fun removeLastOutboxMessageId(server: String) + fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping + } diff --git a/libsession/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt b/libsession/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt new file mode 100644 index 0000000000..3b4af098ad --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt @@ -0,0 +1,8 @@ +package org.session.libsession.messaging + +data class BlindedIdMapping( + val blindedId: String, + val sessionId: String?, + val serverUrl: String, + val serverId: String +) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt index bede188329..92ff9190c5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt @@ -44,7 +44,7 @@ class Contact(val sessionID: String) { // In open groups, where it's more likely that multiple users have the same name, // we display a bit of the Session ID after a user's display name for added context. name?.let { - return "$name (...${sessionID.takeLast(8)})" + return "$name (${sessionID.take(4)}...${sessionID.takeLast(4)})" } return null } diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt similarity index 79% rename from libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt rename to libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index c229aab4be..e50bf62c61 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -6,14 +6,13 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.RequestBody -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 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 -object FileServerAPIV2 { +object FileServerApi { private const val serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" const val server = "http://filev2.getsession.org" @@ -52,8 +51,8 @@ object FileServerAPIV2 { return RequestBody.create(MediaType.get("application/json"), parametersAsJSON) } - private fun send(request: Request): Promise, Exception> { - val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupAPIV2.Error.InvalidURL) + private fun send(request: Request): Promise { + val url = HttpUrl.parse(server) ?: return Promise.ofFail(Error.InvalidURL) val urlBuilder = HttpUrl.Builder() .scheme(url.scheme()) .host(url.host()) @@ -73,29 +72,37 @@ object FileServerAPIV2 { HTTP.Verb.POST -> requestBuilder.post(createBody(request.parameters)!!) HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.parameters)) } - if (request.useOnionRouting) { - return OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).fail { e -> + return if (request.useOnionRouting) { + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map { + it.body ?: throw Error.ParsingFailed + }.fail { e -> Log.e("Loki", "File server request failed.", e) } } else { - return Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) + Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) } } fun upload(file: ByteArray): Promise { val base64EncodedFile = Base64.encodeBytes(file) 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 + val request = Request( + verb = HTTP.Verb.POST, + endpoint = "file", + parameters = parameters, + headers = mapOf( + "Content-Disposition" to "attachment", + "Content-Type" to "application/octet-stream" + ) + ) + return send(request).map { response -> + val json = JsonUtil.fromJson(response, Map::class.java) + json["result"] as? Long ?: throw Error.ParsingFailed } } - fun download(file: Long): Promise { - val request = Request(verb = HTTP.Verb.GET, endpoint = "files/$file") - return send(request).map { json -> - val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed - Base64.decode(base64EncodedFile) ?: throw Error.ParsingFailed - } + fun download(file: String): Promise { + val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file") + return send(request) } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 70d08db019..e329ad342f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -2,7 +2,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.OpenGroupApi 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 @@ -106,15 +106,15 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachment.attachmentId, this.databaseMessageID) tempFile = createTempFile() - val openGroupV2 = storage.getV2OpenGroup(threadID) - if (openGroupV2 == null) { + val openGroup = storage.getOpenGroup(threadID) + if (openGroup == null) { Log.d("AttachmentDownloadJob", "downloading normal attachment") DownloadUtilities.downloadFile(tempFile, attachment.url) } else { Log.d("AttachmentDownloadJob", "downloading open group attachment") val url = HttpUrl.parse(attachment.url)!! val fileID = url.pathSegments().last() - OpenGroupAPIV2.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let { + OpenGroupApi.download(fileID, openGroup.room, openGroup.server).get().let { tempFile.writeBytes(it) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 02475c39cb..360207af43 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -6,9 +6,10 @@ import com.esotericsoftware.kryo.io.Output import nl.komponents.kovenant.Promise import okio.Buffer import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.file_server.FileServerAPIV2 +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.DecodedAudio @@ -50,15 +51,15 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID) ?: return handleFailure(Error.NoAttachment) - val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong()) - if (v2OpenGroup != null) { - val keyAndResult = upload(attachment, v2OpenGroup.server, false) { - OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server) + val openGroup = storage.getOpenGroup(threadID.toLong()) + if (openGroup != null) { + val keyAndResult = upload(attachment, openGroup.server, false) { + OpenGroupApi.upload(it, openGroup.room, openGroup.server) } handleSuccess(attachment, keyAndResult.first, keyAndResult.second) } else { - val keyAndResult = upload(attachment, FileServerAPIV2.server, true) { - FileServerAPIV2.upload(it) + val keyAndResult = upload(attachment, FileServerApi.server, true) { + FileServerApi.upload(it) } handleSuccess(attachment, keyAndResult.first, keyAndResult.second) } @@ -100,7 +101,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val id = upload(data).get() val digest = drb.transmittedDigest // Return - return Pair(key, UploadResult(id, "${server}/files/$id", digest)) + return Pair(key, UploadResult(id, "${server}/file/$id", digest)) } private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { @@ -122,7 +123,25 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess Log.e("Loki", "Couldn't process audio attachment", e) } } - MessagingModuleConfiguration.shared.storage.resumeMessageSendJobIfNeeded(messageSendJobID) + val storage = MessagingModuleConfiguration.shared.storage + storage.getMessageSendJob(messageSendJobID)?.let { + val destination = it.destination as? Destination.OpenGroup ?: return@let + val updatedJob = MessageSendJob( + message = it.message, + destination = Destination.OpenGroup( + destination.roomToken, + destination.server, + destination.whisperTo, + destination.whisperMods, + destination.fileIds + uploadResult.id.toString() + ) + ) + updatedJob.id = it.id + updatedJob.delegate = it.delegate + updatedJob.failureCount = it.failureCount + storage.persistJob(updatedJob) + } + storage.resumeMessageSendJobIfNeeded(messageSendJobID) } private fun handlePermanentFailure(e: Exception) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index e5a3c099fd..af4a2e160f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -2,8 +2,8 @@ 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.OpenGroupV2 +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.GroupUtil import org.session.libsignal.utilities.Log @@ -23,7 +23,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { val openGroupId: String? get() { val url = HttpUrl.parse(joinUrl) ?: return null - val server = OpenGroupV2.getServer(joinUrl)?.toString()?.removeSuffix("/") ?: return null + val server = OpenGroup.getServer(joinUrl)?.toString()?.removeSuffix("/") ?: return null val room = url.pathSegments().firstOrNull() ?: return null return "$server.$room" } @@ -31,25 +31,29 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { override fun execute() { try { val storage = MessagingModuleConfiguration.shared.storage - val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL } - if (allV2OpenGroups.contains(joinUrl)) { - Log.e("OpenGroupDispatcher", "Failed to add group because",DuplicateGroupException()) + val allOpenGroups = storage.getAllOpenGroups().map { it.value.joinURL } + if (allOpenGroups.contains(joinUrl)) { + Log.e("OpenGroupDispatcher", "Failed to add group because", DuplicateGroupException()) delegate?.handleJobFailed(this, DuplicateGroupException()) return } // get image val url = HttpUrl.parse(joinUrl) ?: throw Exception("Group joinUrl isn't valid") - val server = OpenGroupV2.getServer(joinUrl) + val server = OpenGroup.getServer(joinUrl) val serverString = server.toString().removeSuffix("/") val publicKey = url.queryParameter("public_key") ?: throw Exception("Group public key isn't valid") val room = url.pathSegments().firstOrNull() ?: throw Exception("Group room isn't valid") - storage.setOpenGroupPublicKey(serverString,publicKey) - val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(url.pathSegments().firstOrNull()!!, serverString).get() - val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) + storage.setOpenGroupPublicKey(serverString, publicKey) // get info and auth token storage.addOpenGroup(joinUrl) - storage.updateProfilePicture(groupId, bytes) - storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) + val info = OpenGroupApi.getRoomInfo(room, serverString).get() + val imageId = info.imageId + if (imageId != null) { + val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(serverString, room, imageId).get() + val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) + storage.updateProfilePicture(groupId, bytes) + storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) + } storage.onOpenGroupAdded(joinUrl) } catch (e: Exception) { Log.e("OpenGroupDispatcher", "Failed to add group because",e) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index d44a72e3e0..9b899bbe27 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -17,8 +17,11 @@ import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.protos.UtilProtos +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log data class MessageReceiveParameters( @@ -72,12 +75,13 @@ class BatchMessageReceiveJob( val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val localUserPublicKey = storage.getUserPublicKey() + val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } // parse and collect IDs messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID) + val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey) message.serverHash = serverHash val threadID = getThreadId(message, storage) val parsedParams = ParsedMessage(messageParameters, message, proto) @@ -111,7 +115,9 @@ class BatchMessageReceiveJob( runProfileUpdate = true ) if (messageId != null) { - messageIds += messageId to (message.sender == localUserPublicKey) + val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId( + IdPrefix.BLINDED, it.publicKey.asBytes).hexString } + messageIds += messageId to (message.sender == localUserPublicKey || isUserBlindedSender) } } else { MessageReceiver.handle(message, proto, openGroupID) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index 227944a889..38e8831fba 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -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.OpenGroupApi import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.GroupUtil @@ -15,8 +15,9 @@ 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 = OpenGroupApi.getRoomInfo(room, server).get() + val imageId = info.imageId ?: return + val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, info.token, imageId).get() val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) storage.updateProfilePicture(groupId, bytes) storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt index 2377a7edce..439fbb7a3a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt @@ -2,6 +2,7 @@ package org.session.libsession.messaging.jobs import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.utilities.Data @@ -32,7 +33,10 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val val deferred = deferred() try { val isRetry: Boolean = failureCount != 0 - val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID) + val serverPublicKey = openGroupID?.let { + MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) + } + val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey) message.serverHash = serverHash MessageReceiver.handle(message, proto, this.openGroupID) this.handleSuccess() diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index ed1bed3c0d..3016e068f4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -3,8 +3,6 @@ package org.session.libsession.messaging.jobs import com.esotericsoftware.kryo.Kryo import com.esotericsoftware.kryo.io.Input import com.esotericsoftware.kryo.io.Output -import nl.komponents.kovenant.FailedException -import nl.komponents.kovenant.Promise import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE import org.session.libsession.messaging.messages.Destination diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 90262fc358..5c393c97b5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -13,6 +13,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.PushNoti import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.Version import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.JsonUtil @@ -38,10 +39,10 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(4) { - OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, "/loki/v2/lsrpc").map { json -> - val code = json["code"] as? Int + OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, Version.V2).map { response -> + val code = response.info["code"] as? Int if (code == null || code == 0) { - Log.d("Loki", "Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.") + Log.d("Loki", "Couldn't notify PN server due to error: ${response.info["message"] as? String ?: "null"}.") } }.fail { exception -> Log.d("Loki", "Couldn't notify PN server due to error: $exception.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index e08125e58e..c4180c0025 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -23,11 +23,13 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size Log.d(TAG, "Deleting $numberToDelete messages") + var numberDeleted = 0 messageServerIds.forEach { serverId -> val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach dataProvider.deleteMessage(messageId, isSms) + numberDeleted++ } - Log.d(TAG, "Deleted $numberToDelete messages successfully") + Log.d(TAG, "Deleted $numberDeleted messages successfully") delegate?.handleJobSucceeded(this) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt index ac6d97874c..3abf0ed3e1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.messages import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsignal.utilities.toHexString @@ -14,13 +13,27 @@ sealed class Destination { class ClosedGroup(var groupPublicKey: String) : Destination() { internal constructor(): this("") } - class OpenGroupV2(var room: String, var server: String) : Destination() { + class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { internal constructor(): this("", "") } + class OpenGroup( + var roomToken: String = "", + var server: String = "", + var whisperTo: List = emptyList(), + var whisperMods: Boolean = false, + var fileIds: List = emptyList() + ) : Destination() + + class OpenGroupInbox( + var server: String, + var serverPublicKey: String, + var blindedPublicKey: String + ) : Destination() + companion object { - fun from(address: Address): Destination { + fun from(address: Address, fileIds: List = emptyList()): Destination { return when { address.isContact -> { Contact(address.contactIdentifier()) @@ -33,11 +46,17 @@ sealed class Destination { address.isOpenGroup -> { val storage = MessagingModuleConfiguration.shared.storage val threadID = storage.getThreadId(address)!! - when (val openGroup = storage.getV2OpenGroup(threadID)) { - is org.session.libsession.messaging.open_groups.OpenGroupV2 - -> Destination.OpenGroupV2(openGroup.room, openGroup.server) - else -> throw Exception("Missing open group for thread with ID: $threadID.") - } + storage.getOpenGroup(threadID)?.let { + OpenGroup(roomToken = it.room, server = it.server, fileIds = fileIds) + } ?: throw Exception("Missing open group for thread with ID: $threadID.") + } + address.isOpenGroupInbox -> { + val groupInboxId = GroupUtil.getDecodedGroupID(address.serialize()).split("!") + OpenGroupInbox( + groupInboxId.dropLast(2).joinToString("!"), + groupInboxId.dropLast(1).last(), + groupInboxId.last() + ) } else -> { throw Exception("TODO: Handle legacy closed groups.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt index f4cfd9202c..c7aa03a7b2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt @@ -1,16 +1,12 @@ package org.session.libsession.messaging.messages.control import com.google.protobuf.ByteString -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.GroupUtil import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.DataMessage -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log @@ -140,7 +136,7 @@ class ClosedGroupControlMessage() : ControlMessage() { closedGroupControlMessage.publicKey = kind.publicKey closedGroupControlMessage.name = kind.name val encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder() - encryptionKeyPair.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removing05PrefixIfNeeded()) + encryptionKeyPair.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) encryptionKeyPair.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize()) closedGroupControlMessage.encryptionKeyPair = encryptionKeyPair.build() closedGroupControlMessage.addAllMembers(kind.members) diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt index 2b33173d96..30a47ab85b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt @@ -11,7 +11,7 @@ import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.Hex @@ -36,7 +36,7 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: val publicKey = proto.publicKey.toByteArray().toHexString() val name = proto.name val encryptionKeyPairAsProto = proto.encryptionKeyPair - val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray().removing05PrefixIfNeeded()), + val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray().removingIdPrefixIfNeeded()), DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) val members = proto.membersList.map { it.toByteArray().toHexString() } val admins = proto.adminsList.map { it.toByteArray().toHexString() } @@ -50,7 +50,7 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey)) result.name = name val encryptionKeyPairAsProto = SignalServiceProtos.KeyPair.newBuilder() - encryptionKeyPairAsProto.publicKey = ByteString.copyFrom(encryptionKeyPair!!.publicKey.serialize().removing05PrefixIfNeeded()) + encryptionKeyPairAsProto.publicKey = ByteString.copyFrom(encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) encryptionKeyPairAsProto.privateKey = ByteString.copyFrom(encryptionKeyPair!!.privateKey.serialize()) result.encryptionKeyPair = encryptionKeyPairAsProto.build() result.addAllMembers(members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) @@ -134,8 +134,8 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: } if (group.isOpenGroup) { val threadID = storage.getThreadId(group.encodedId) ?: continue - val openGroupV2 = storage.getV2OpenGroup(threadID) - val shareUrl = openGroupV2?.joinURL ?: continue + val openGroup = storage.getOpenGroup(threadID) + val shareUrl = openGroup?.joinURL ?: continue openGroups.add(shareUrl) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt new file mode 100644 index 0000000000..79abfeb59a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt @@ -0,0 +1,72 @@ +package org.session.libsession.messaging.open_groups + +sealed class Endpoint(val value: String) { + + object Onion : Endpoint("oxen/v4/lsrpc") + object Batch : Endpoint("batch") + object Sequence : Endpoint("sequence") + object Capabilities : Endpoint("capabilities") + + // Rooms + + object Rooms : Endpoint("rooms") + data class Room(val roomToken: String) : Endpoint("room/$roomToken") + data class RoomPollInfo(val roomToken: String, val infoUpdated: Int) : + Endpoint("room/$roomToken/pollInfo/$infoUpdated") + + // Messages + + data class RoomMessage(val roomToken: String) : Endpoint("room/$roomToken/message") + + data class RoomMessageIndividual(val roomToken: String, val messageId: Long) : + Endpoint("room/$roomToken/message/$messageId") + + data class RoomMessagesRecent(val roomToken: String) : + Endpoint("room/$roomToken/messages/recent") + + data class RoomMessagesBefore(val roomToken: String, val messageId: Long) : + Endpoint("room/$roomToken/messages/before/$messageId") + + data class RoomMessagesSince(val roomToken: String, val seqNo: Long) : + Endpoint("room/$roomToken/messages/since/$seqNo") + + data class RoomDeleteMessages(val roomToken: String, val sessionId: String) : + Endpoint("room/$roomToken/all/$sessionId") + + // Pinning + + data class RoomPinMessage(val roomToken: String, val messageId: Long) : + Endpoint("room/$roomToken/pin/$messageId") + + data class RoomUnpinMessage(val roomToken: String, val messageId: Long) : + Endpoint("room/$roomToken/unpin/$messageId") + + data class RoomUnpinAll(val roomToken: String) : Endpoint("room/$roomToken/unpin/all") + + // Files + + object File: Endpoint("file") + data class FileIndividual(val fileId: Long): Endpoint("file/$fileId") + + data class RoomFile(val roomToken: String) : Endpoint("room/$roomToken/file") + data class RoomFileIndividual( + val roomToken: String, + val fileId: String + ) : Endpoint("room/$roomToken/file/$fileId") + + // Inbox/Outbox (Message Requests) + + object Inbox : Endpoint("inbox") + data class InboxSince(val id: Long) : Endpoint("inbox/since/$id") + data class InboxFor(val sessionId: String) : Endpoint("inbox/$sessionId") + + object Outbox : Endpoint("outbox") + data class OutboxSince(val id: Long) : Endpoint("outbox/since/$id") + + // Users + + data class UserBan(val sessionId: String) : Endpoint("user/$sessionId/ban") + data class UserUnban(val sessionId: String) : Endpoint("user/$sessionId/unban") + data class UserModerator(val sessionId: String) : Endpoint("user/$sessionId/moderator") + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt new file mode 100644 index 0000000000..0559a12d5d --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt @@ -0,0 +1,11 @@ +package org.session.libsession.messaging.open_groups + +data class GroupMember( + val groupId: String, + val profileId: String, + val role: GroupMemberRole +) + +enum class GroupMemberRole { + STANDARD, ZOOMBIE, MODERATOR, ADMIN +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt similarity index 72% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt rename to libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt index 886c359c5e..9efeaf15d0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt @@ -5,25 +5,27 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import java.util.Locale -data class OpenGroupV2( +data class OpenGroup( val server: String, val room: String, val id: String, val name: String, - val publicKey: String + val publicKey: String, + val infoUpdates: Int, ) { - constructor(server: String, room: String, name: String, publicKey: String) : this( + constructor(server: String, room: String, name: String, infoUpdates: Int, publicKey: String) : this( server = server, room = room, id = "$server.$room", name = name, publicKey = publicKey, + infoUpdates = infoUpdates, ) companion object { - fun fromJSON(jsonAsString: String): OpenGroupV2? { + fun fromJSON(jsonAsString: String): OpenGroup? { return try { val json = JsonUtil.fromJson(jsonAsString) if (!json.has("room")) return null @@ -31,7 +33,9 @@ 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 infoUpdates = json.get("infoUpdates")?.asText()?.toIntOrNull() ?: 0 + val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList() + OpenGroup(server, room, displayName, infoUpdates, publicKey) } catch (e: Exception) { Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); null @@ -54,7 +58,10 @@ data class OpenGroupV2( "server" to server, "displayName" to name, "publicKey" to publicKey, + "infoUpdates" to infoUpdates.toString(), ) val joinURL: String get() = "$server/$room?public_key=$publicKey" + + val groupId: String get() = "$server.$room" } \ No newline at end of file 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 deleted file mode 100644 index 6992f69479..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt +++ /dev/null @@ -1,499 +0,0 @@ -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 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.OpenGroupPollerV2 -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.utilities.AESGCM -import org.session.libsession.utilities.TextSecurePreferences -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 kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set - -object OpenGroupAPIV2 { - private val moderators: HashMap> = hashMapOf() // Server URL to (channel ID to set of moderator IDs) - private val curve = Curve25519.getInstance(Curve25519.BEST) - val defaultRooms = MutableSharedFlow>(replay = 1) - private val hasPerformedInitialPoll = mutableMapOf() - private var hasUpdatedLastOpenDate = false - - private val timeSinceLastOpen by lazy { - val context = MessagingModuleConfiguration.shared.context - val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context) - val now = System.currentTimeMillis() - now - lastOpenDate - } - - 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.") - } - - 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 CompactPollRequest(val roomID: String, val authToken: String, val fromDeletionServerID: Long?, val fromMessageServerID: Long?) - data class CompactPollResult(val messages: List, val deletions: List, val moderators: List) - - 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 = mapOf(), - val parameters: Any? = null, - val headers: Map = mapOf(), - val isAuthRequired: Boolean = true, - /** - * Always `true` under normal circumstances. You might want to disable - * this when running over Lokinet. - */ - val useOnionRouting: 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, 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, Exception> { - val requestBuilder = okhttp3.Request.Builder() - .url(urlBuilder.build()) - .headers(Headers.of(request.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) { - val publicKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) - ?: return Promise.ofFail(Error.NoPublicKey) - 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 { - 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 { - 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) - } - .fail { exception -> - Log.e("Loki", "Failed to get auth token", exception) - } - } - } - - fun requestNewAuthToken(room: String, server: String): Promise { - 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 { - 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 } - } - - fun deleteAuthToken(room: String, server: String): Promise { - val request = Request(verb = DELETE, room = room, server = server, endpoint = "auth_token") - return send(request).map { - MessagingModuleConfiguration.shared.storage.removeAuthToken(room, server) - } - } - // endregion - - // region Upload/Download - fun upload(file: ByteArray, room: String, server: String): Promise { - 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 { - 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 { - 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 - ?: 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, Exception> { - val storage = MessagingModuleConfiguration.shared.storage - val queryParameters = mutableMapOf() - 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> - ?: throw Error.ParsingFailed - parseMessages(room, server, rawMessages) - } - } - - private fun parseMessages(room: String, server: String, rawMessages: List>): List { - val messages = rawMessages.mapNotNull { json -> - json as Map - 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 { - 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, Exception> { - val storage = MessagingModuleConfiguration.shared.storage - val queryParameters = mutableMapOf() - 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>(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) { - moderators[serverRoomId] = moderatorList.toMutableSet() - } - - fun getModerators(room: String, server: String): Promise, 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 - ?: throw Error.ParsingFailed - val id = "$server.$room" - handleModerators(id, moderatorsJson) - moderatorsJson - } - } - - @JvmStatic - fun ban(publicKey: String, room: String, server: String): Promise { - 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 { - 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 { - 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 compactPoll(rooms: List, server: String): Promise, 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 > OpenGroupPollerV2.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 - CompactPollRequest( - 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 = "compact_poll", 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 ?: 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>(idsAsString, type) ?: throw Error.ParsingFailed - // Messages - val rawMessages = json["messages"] as? List> ?: return@mapNotNull null - val messages = parseMessages(roomID, server, rawMessages) - roomID to CompactPollResult( - messages = messages, - deletions = deletions, - moderators = moderators - ) - }.toMap() - } - } - - fun getDefaultRoomsIfNeeded(): Promise, 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.map { group -> - group.id to downloadOpenGroupProfilePicture(group.id, defaultServer) - }.toMap() - 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 { - 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, 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> ?: 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 { - val request = Request(verb = GET, room = room, server = server, endpoint = "member_count") - return send(request).map { json -> - val memberCount = json["member_count"] as? Int ?: throw Error.ParsingFailed - val storage = MessagingModuleConfiguration.shared.storage - storage.setUserCount(room, server, memberCount) - memberCount - } - } - // endregion -} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt new file mode 100644 index 0000000000..fb4b6a7b90 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -0,0 +1,830 @@ +package org.session.libsession.messaging.open_groups + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.type.TypeReference +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 com.goterl.lazysodium.interfaces.GenericHash +import com.goterl.lazysodium.interfaces.Sign +import kotlinx.coroutines.flow.MutableSharedFlow +import nl.komponents.kovenant.Promise +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.OpenGroupPoller.Companion.maxInactivityPeriod +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.OnionResponse +import org.session.libsession.utilities.TextSecurePreferences +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.IdPrefix +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.whispersystems.curve25519.Curve25519 +import java.util.concurrent.TimeUnit +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +object OpenGroupApi { + private val curve = Curve25519.getInstance(Curve25519.BEST) + val defaultRooms = MutableSharedFlow>(replay = 1) + private val hasPerformedInitialPoll = mutableMapOf() + 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 + } + + 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" + } + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class RoomInfo( + val token: String = "", + val name: String = "", + val description: String = "", + val infoUpdates: Int = 0, + val messageSequence: Long = 0, + val created: Long = 0, + val activeUsers: Int = 0, + val activeUsersCutoff: Int = 0, + val imageId: Long? = null, + val pinnedMessages: List = emptyList(), + val admin: Boolean = false, + val globalAdmin: Boolean = false, + val admins: List = emptyList(), + val hiddenAdmins: List = emptyList(), + val moderator: Boolean = false, + val globalModerator: Boolean = false, + val moderators: List = emptyList(), + val hiddenModerators: List = emptyList(), + val read: Boolean = false, + val defaultRead: Boolean = false, + val defaultAccessible: Boolean = false, + val write: Boolean = false, + val defaultWrite: Boolean = false, + val upload: Boolean = false, + val defaultUpload: Boolean = false, + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class PinnedMessage( + val id: Long = 0, + val pinnedAt: Long = 0, + val pinnedBy: String = "" + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class BatchRequestInfo( + val request: BatchRequest, + val endpoint: Endpoint, + val responseType: TypeReference + ) + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class BatchRequest( + val method: HTTP.Verb, + val path: String, + val headers: Map = emptyMap(), + val json: Map? = null, + val b64: String? = null, + val bytes: ByteArray? = null, + ) + + data class BatchResponse( + val endpoint: Endpoint, + val code: Int, + val headers: Map, + val body: T? + ) + + data class Capabilities( + val capabilities: List = emptyList(), + val missing: List = emptyList() + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class RoomPollInfo( + val token: String = "", + val activeUsers: Int = 0, + val admin: Boolean = false, + val globalAdmin: Boolean = false, + val moderator: Boolean = false, + val globalModerator: Boolean = false, + val read: Boolean = false, + val defaultRead: Boolean = false, + val defaultAccessible: Boolean = false, + val write: Boolean = false, + val defaultWrite: Boolean = false, + val upload: Boolean = false, + val defaultUpload: Boolean = false, + val details: RoomInfo? = null + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class DirectMessage( + val id: Long = 0, + val sender: String = "", + val recipient: String = "", + val postedAt: Long = 0, + val expiresAt: Long = 0, + val message: String = "", + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class Message( + val id : Long = 0, + val sessionId: String = "", + val posted: Double = 0.0, + val edited: Long = 0, + val seqno: Long = 0, + val deleted: Boolean = false, + val whisper: Boolean = false, + val whisperMods: String = "", + val whisperTo: String = "", + val data: String? = null, + val signature: String? = null + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class SendMessageRequest( + val data: String? = null, + val signature: String? = null, + val whisperTo: List? = null, + val whisperMods: Boolean? = null, + val files: List? = null + ) + + 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: Endpoint, + val queryParameters: Map = mapOf(), + val parameters: Any? = null, + val headers: Map = mapOf(), + val isAuthRequired: Boolean = true, + /** + * Always `true` under normal circumstances. You might want to disable + * this when running over Lokinet. + */ + val useOnionRouting: 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 getResponseBody(request: Request): Promise { + return send(request).map { response -> + response.body ?: throw Error.ParsingFailed + } + } + + private fun getResponseBodyJson(request: Request): Promise, Exception> { + return send(request).map { + JsonUtil.fromJson(it.body, Map::class.java) + } + } + + private fun send(request: Request): Promise { + 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.value) + if (request.verb == GET) { + for ((key, value) in request.queryParameters) { + urlBuilder.addQueryParameter(key, value) + } + } + fun execute(): Promise { + val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(request.server) + 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() + if (request.isAuthRequired) { + val nonce = sodium.nonce(16) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + var pubKey = "" + var signature = ByteArray(Sign.BYTES) + var bodyHash = ByteArray(0) + if (request.parameters != null) { + val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() + val parameterHash = ByteArray(GenericHash.BYTES_MAX) + if (sodium.cryptoGenericHash( + parameterHash, + parameterHash.size, + parameterBytes, + parameterBytes.size.toLong() + ) + ) { + bodyHash = parameterHash + } + } + val messageBytes = Hex.fromStringCondensed(publicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus(urlRequest.encodedPath().toByteArray()) + .plus(bodyHash) + if (serverCapabilities.contains("blind")) { + SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + pubKey = SessionId( + IdPrefix.BLINDED, + keyPair.publicKey.asBytes + ).hexString + + signature = SodiumUtilities.sogsSignature( + messageBytes, + ed25519KeyPair.secretKey.asBytes, + keyPair.secretKey.asBytes, + keyPair.publicKey.asBytes + ) ?: return Promise.ofFail(Error.SigningFailed) + } ?: return Promise.ofFail(Error.SigningFailed) + } else { + pubKey = SessionId( + IdPrefix.UN_BLINDED, + ed25519KeyPair.publicKey.asBytes + ).hexString + sodium.cryptoSignDetached( + signature, + messageBytes, + messageBytes.size.toLong(), + ed25519KeyPair.secretKey.asBytes + ) + } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) + } + + val requestBuilder = okhttp3.Request.Builder() + .url(urlRequest) + .headers(Headers.of(headers)) + 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) + } + return if (request.useOnionRouting) { + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e -> + Log.e("SOGS", "Failed onion request", e) + } + } else { + Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) + } + } + return execute() + } + + fun downloadOpenGroupProfilePicture( + server: String, + roomID: String, + imageId: Long + ): Promise { + val request = Request( + verb = GET, + room = roomID, + server = server, + endpoint = Endpoint.RoomFileIndividual(roomID, imageId.toString()) + ) + return getResponseBody(request) + } + + // region Upload/Download + fun upload(file: ByteArray, room: String, server: String): Promise { + val parameters = mapOf("file" to file) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = Endpoint.RoomFile(room), + parameters = parameters + ) + return getResponseBodyJson(request).map { json -> + (json["id"] as? Number)?.toLong() ?: throw Error.ParsingFailed + } + } + + fun download(fileId: String, room: String, server: String): Promise { + val request = Request( + verb = GET, + room = room, + server = server, + endpoint = Endpoint.RoomFileIndividual(room, fileId) + ) + return getResponseBody(request) + } + // endregion + + // region Sending + fun sendMessage( + message: OpenGroupMessage, + room: String, + server: String, + whisperTo: List? = null, + whisperMods: Boolean? = null, + fileIds: List? = null + ): Promise { + val signedMessage = message.sign(room, server, fallbackSigningType = IdPrefix.STANDARD) ?: return Promise.ofFail(Error.SigningFailed) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = Endpoint.RoomMessage(room), + parameters = signedMessage.toJSON() + ) + return getResponseBodyJson(request).map { json -> + @Suppress("UNCHECKED_CAST") val rawMessage = json as? Map + ?: throw Error.ParsingFailed + val result = OpenGroupMessage.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, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + val queryParameters = mutableMapOf() + storage.getLastMessageServerID(room, server)?.let { lastId -> + queryParameters += "from_server_id" to lastId.toString() + } + val request = Request( + verb = GET, + room = room, + server = server, + endpoint = Endpoint.RoomMessage(room), + queryParameters = queryParameters + ) + return getResponseBodyJson(request).map { json -> + @Suppress("UNCHECKED_CAST") val rawMessages = + json["messages"] as? List> + ?: throw Error.ParsingFailed + parseMessages(room, server, rawMessages) + } + } + + private fun parseMessages( + room: String, + server: String, + rawMessages: List> + ): List { + val messages = rawMessages.mapNotNull { json -> + json as Map + try { + val message = OpenGroupMessage.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.removingIdPrefixIfNeeded()) + 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 { + val request = + Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) + return send(request).map { + Log.d("Loki", "Message deletion successful.") + } + } + + fun getDeletedMessages( + room: String, + server: String + ): Promise, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + val queryParameters = mutableMapOf() + storage.getLastDeletionServerID(room, server)?.let { last -> + queryParameters["from_server_id"] = last.toString() + } + val request = Request( + verb = GET, + room = room, + server = server, + endpoint = Endpoint.RoomDeleteMessages(room, storage.getUserPublicKey() ?: ""), + queryParameters = queryParameters + ) + return getResponseBody(request).map { response -> + val json = JsonUtil.fromJson(response, Map::class.java) + val type = TypeFactory.defaultInstance() + .constructCollectionType(List::class.java, MessageDeletion::class.java) + val idsAsString = JsonUtil.toJson(json["ids"]) + val serverIDs = JsonUtil.fromJson>(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 + @JvmStatic + fun ban(publicKey: String, room: String, server: String): Promise { + val parameters = mapOf("rooms" to listOf(room)) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = Endpoint.UserBan(publicKey), + parameters = parameters + ) + return send(request).map { + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") + } + } + + fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise { + val requests = mutableListOf>( + BatchRequestInfo( + request = BatchRequest( + method = POST, + path = "/user/$publicKey/ban", + json = mapOf("rooms" to listOf(room)) + ), + endpoint = Endpoint.UserBan(publicKey), + responseType = object: TypeReference(){} + ), + BatchRequestInfo( + request = BatchRequest(DELETE, "/room/$room/all/$publicKey"), + endpoint = Endpoint.RoomDeleteMessages(room, publicKey), + responseType = object: TypeReference(){} + ) + ) + return sequentialBatch(server, requests).map { + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") + } + } + + fun unban(publicKey: String, room: String, server: String): Promise { + val request = + Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey)) + return send(request).map { + Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") + } + } + // endregion + + // region General + @Suppress("UNCHECKED_CAST") + fun poll( + rooms: List, + server: String + ): Promise>, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + val context = MessagingModuleConfiguration.shared.context + val timeSinceLastOpen = this.timeSinceLastOpen + val shouldRetrieveRecentMessages = (hasPerformedInitialPoll[server] != true + && timeSinceLastOpen > maxInactivityPeriod) + hasPerformedInitialPoll[server] = true + if (!hasUpdatedLastOpenDate) { + hasUpdatedLastOpenDate = true + TextSecurePreferences.setLastOpenDate(context) + } + val lastInboxMessageId = storage.getLastInboxMessageId(server) + val lastOutboxMessageId = storage.getLastOutboxMessageId(server) + val requests = mutableListOf>( + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/capabilities" + ), + endpoint = Endpoint.Capabilities, + responseType = object : TypeReference(){} + ) + ) + rooms.forEach { room -> + val infoUpdates = storage.getOpenGroup(room, server)?.infoUpdates ?: 0 + requests.add( + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/room/$room/pollInfo/$infoUpdates" + ), + endpoint = Endpoint.RoomPollInfo(room, infoUpdates), + responseType = object : TypeReference(){} + ) + ) + requests.add( + if (shouldRetrieveRecentMessages) { + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/room/$room/messages/recent" + ), + endpoint = Endpoint.RoomMessagesRecent(room), + responseType = object : TypeReference>(){} + ) + } else { + val lastMessageServerId = storage.getLastMessageServerID(room, server) ?: 0L + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/room/$room/messages/since/$lastMessageServerId" + ), + endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId), + responseType = object : TypeReference>(){} + ) + } + ) + } + val serverCapabilities = storage.getServerCapabilities(server) + if (serverCapabilities.contains("blind")) { + requests.add( + if (lastInboxMessageId == null) { + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/inbox" + ), + endpoint = Endpoint.Inbox, + responseType = object : TypeReference>() {} + ) + } else { + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/inbox/since/$lastInboxMessageId" + ), + endpoint = Endpoint.InboxSince(lastInboxMessageId), + responseType = object : TypeReference>() {} + ) + } + ) + requests.add( + if (lastOutboxMessageId == null) { + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/outbox" + ), + endpoint = Endpoint.Outbox, + responseType = object : TypeReference>() {} + ) + } else { + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/outbox/since/$lastOutboxMessageId" + ), + endpoint = Endpoint.OutboxSince(lastOutboxMessageId), + responseType = object : TypeReference>() {} + ) + } + ) + } + return parallelBatch(server, requests) + } + + private fun parallelBatch( + server: String, + requests: MutableList> + ): Promise>, Exception> { + val request = Request( + verb = POST, + room = null, + server = server, + endpoint = Endpoint.Batch, + parameters = requests.map { it.request } + ) + return getBatchResponseJson(request, requests) + } + + private fun sequentialBatch( + server: String, + requests: MutableList> + ): Promise>, Exception> { + val request = Request( + verb = POST, + room = null, + server = server, + endpoint = Endpoint.Sequence, + parameters = requests.map { it.request } + ) + return getBatchResponseJson(request, requests) + } + + private fun getBatchResponseJson( + request: Request, + requests: MutableList> + ): Promise>, Exception> { + return getResponseBody(request).map { batch -> + val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed + results.mapIndexed { idx, result -> + val response = result as? Map<*, *> ?: throw Error.ParsingFailed + val code = response["code"] as Int + BatchResponse( + endpoint = requests[idx].endpoint, + code = code, + headers = response["headers"] as Map, + body = if (code in 200..299) { + JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let { + JsonUtil.fromJson(it, requests[idx].responseType) + } + } else null + ) + } + } + } + + fun getDefaultServerCapabilities(): Promise { + val storage = MessagingModuleConfiguration.shared.storage + storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey) + return getCapabilities(defaultServer).map { capabilities -> + storage.setServerCapabilities(defaultServer, capabilities.capabilities) + capabilities + } + } + + fun getDefaultRoomsIfNeeded(): Promise, Exception> { + return getAllRooms().map { groups -> + val earlyGroups = groups.map { group -> + DefaultGroup(group.token, 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.token to group.imageId?.let { downloadOpenGroupProfilePicture(defaultServer, group.token, it) } + } + groups.map { group -> + val image = try { + images[group.token]!!.get() + } catch (e: Exception) { + // No image or image failed to download + null + } + DefaultGroup(group.token, group.name, image) + } + }.success { new -> + defaultRooms.tryEmit(new) + } + } + + fun getRoomInfo(roomToken: String, server: String): Promise { + val request = Request( + verb = GET, + room = null, + server = server, + endpoint = Endpoint.Room(roomToken) + ) + return getResponseBody(request).map { response -> + JsonUtil.fromJson(response, RoomInfo::class.java) + } + } + + private fun getAllRooms(): Promise, Exception> { + val request = Request( + verb = GET, + room = null, + server = defaultServer, + endpoint = Endpoint.Rooms + ) + return getResponseBody(request).map { response -> + val rawRooms = JsonUtil.fromJson(response, List::class.java) ?: throw Error.ParsingFailed + rawRooms.mapNotNull { + JsonUtil.fromJson(JsonUtil.toJson(it), RoomInfo::class.java) + } + } + } + + fun getMemberCount(room: String, server: String): Promise { + return getRoomInfo(room, server).map { info -> + val storage = MessagingModuleConfiguration.shared.storage + storage.setUserCount(room, server, info.activeUsers) + info.activeUsers + } + } + + fun getCapabilities(server: String): Promise { + val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities, isAuthRequired = false) + return getResponseBody(request).map { response -> + JsonUtil.fromJson(response, Capabilities::class.java) + } + } + + fun getCapabilitiesAndRoomInfo(room: String, server: String): Promise, Exception> { + val requests = mutableListOf>( + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/capabilities" + ), + endpoint = Endpoint.Capabilities, + responseType = object : TypeReference(){} + ), + BatchRequestInfo( + request = BatchRequest( + method = GET, + path = "/room/$room" + ), + endpoint = Endpoint.Room(room), + responseType = object : TypeReference(){} + ) + ) + return sequentialBatch(server, requests).map { + val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed + val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed + capabilities to roomInfo + } + } + + fun sendDirectMessage(message: String, blindedSessionId: String, server: String): Promise { + val request = Request( + verb = POST, + room = null, + server = server, + endpoint = Endpoint.InboxFor(blindedSessionId), + parameters = mapOf("message" to message) + ) + return getResponseBody(request).map { response -> + JsonUtil.fromJson(response, DirectMessage::class.java) + } + } + + // endregion +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt new file mode 100644 index 0000000000..db9ae0c0c0 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -0,0 +1,93 @@ +package org.session.libsession.messaging.open_groups + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsignal.crypto.PushTransportDetails +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Base64.decode +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.session.libsignal.utilities.toHexString +import org.whispersystems.curve25519.Curve25519 + +data class OpenGroupMessage( + val serverID: Long? = null, + val sender: String?, + val sentTimestamp: Long, + /** + * The serialized protobuf in base64 encoding. + */ + val base64EncodedData: String, + /** + * When sending a message, the sender signs the serialized protobuf with their private key so that + * a receiving user can verify that the message wasn't tampered with. + */ + val base64EncodedSignature: String? = null +) { + + companion object { + private val curve = Curve25519.getInstance(Curve25519.BEST) + + fun fromJSON(json: Map): OpenGroupMessage? { + val base64EncodedData = json["data"] as? String ?: return null + val sentTimestamp = json["posted"] as? Double ?: return null + val serverID = json["id"] as? Int + val sender = json["session_id"] as? String + val base64EncodedSignature = json["signature"] as? String + return OpenGroupMessage( + serverID = serverID?.toLong(), + sender = sender, + sentTimestamp = (sentTimestamp * 1000).toLong(), + base64EncodedData = base64EncodedData, + base64EncodedSignature = base64EncodedSignature + ) + } + } + + fun sign(room: String, server: String, fallbackSigningType: IdPrefix): OpenGroupMessage? { + if (base64EncodedData.isEmpty()) return null + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null + val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server) + val signature = when { + serverCapabilities.contains("blind") -> { + val blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.publicKey, userEdKeyPair) ?: return null + SodiumUtilities.sogsSignature( + decode(base64EncodedData), + userEdKeyPair.secretKey.asBytes, + blindedKeyPair.secretKey.asBytes, + blindedKeyPair.publicKey.asBytes + ) ?: return null + } + fallbackSigningType == IdPrefix.UN_BLINDED -> { + curve.calculateSignature(userEdKeyPair.secretKey.asBytes, decode(base64EncodedData)) + } + else -> { + val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() } + if (sender != publicKey.toHexString() && !userEdKeyPair.publicKey.asHexString.equals(sender?.removingIdPrefixIfNeeded(), true)) return null + try { + curve.calculateSignature(privateKey, decode(base64EncodedData)) + } catch (e: Exception) { + Log.w("Loki", "Couldn't sign open group message.", e) + return null + } + } + } + return copy(base64EncodedSignature = Base64.encodeBytes(signature)) + } + + fun toJSON(): Map { + val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp ) + serverID?.let { json["server_id"] = it } + sender?.let { json["public_key"] = it } + base64EncodedSignature?.let { json["signature"] = it } + return json + } + + fun toProto(): SignalServiceProtos.Content { + val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody) + return SignalServiceProtos.Content.parseFrom(data) + } +} \ No newline at end of file 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 deleted file mode 100644 index 92cb47ae34..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.session.libsession.messaging.open_groups - -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsignal.crypto.PushTransportDetails -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( - val serverID: Long? = null, - val sender: String?, - val sentTimestamp: Long, - /** - * The serialized protobuf in base64 encoding. - */ - val base64EncodedData: String, - /** - * When sending a message, the sender signs the serialized protobuf with their private key so that - * a receiving user can verify that the message wasn't tampered with. - */ - val base64EncodedSignature: String? = null -) { - - companion object { - private val curve = Curve25519.getInstance(Curve25519.BEST) - - fun fromJSON(json: Map): OpenGroupMessageV2? { - val base64EncodedData = json["data"] as? String ?: return null - val sentTimestamp = json["timestamp"] as? Long ?: return null - val serverID = json["server_id"] as? Int - val sender = json["public_key"] as? String - val base64EncodedSignature = json["signature"] as? String - return OpenGroupMessageV2( - serverID = serverID?.toLong(), - sender = sender, - sentTimestamp = sentTimestamp, - base64EncodedData = base64EncodedData, - base64EncodedSignature = base64EncodedSignature - ) - } - - } - - fun sign(): OpenGroupMessageV2? { - if (base64EncodedData.isEmpty()) 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.serialize(), decode(base64EncodedData)) - } catch (e: Exception) { - Log.w("Loki", "Couldn't sign open group message.", e) - return null - } - return copy(base64EncodedSignature = Base64.encodeBytes(signature)) - } - - fun toJSON(): Map { - val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp ) - serverID?.let { json["server_id"] = it } - sender?.let { json["public_key"] = it } - base64EncodedSignature?.let { json["signature"] = it } - return json - } - - fun toProto(): SignalServiceProtos.Content { - val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody) - return SignalServiceProtos.Content.parseFrom(data) - } -} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index da9d080aed..5753f696d3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -5,11 +5,15 @@ import com.goterl.lazysodium.LazySodiumAndroid import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.interfaces.Box import com.goterl.lazysodium.interfaces.Sign +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.hexEncodedPublicKey -import org.session.libsignal.utilities.removing05PrefixIfNeeded -import org.session.libsignal.utilities.toHexString +import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageDecrypter { @@ -25,7 +29,7 @@ object MessageDecrypter { */ public fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() - val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded()) + val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) val signatureSize = Sign.BYTES val ed25519PublicKeySize = Sign.PUBLICKEYBYTES @@ -35,9 +39,9 @@ object MessageDecrypter { sodium.cryptoBoxSealOpen(plaintextWithMetadata, ciphertext, ciphertext.size.toLong(), recipientX25519PublicKey, recipientX25519PrivateKey) } catch (exception: Exception) { Log.d("Loki", "Couldn't decrypt message due to error: $exception.") - throw MessageReceiver.Error.DecryptionFailed + throw Error.DecryptionFailed } - if (plaintextWithMetadata.size <= (signatureSize + ed25519PublicKeySize)) { throw MessageReceiver.Error.DecryptionFailed } + if (plaintextWithMetadata.size <= (signatureSize + ed25519PublicKeySize)) { throw Error.DecryptionFailed } // 2. ) Get the message parts val signature = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - signatureSize until plaintextWithMetadata.size) val senderED25519PublicKey = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize) until plaintextWithMetadata.size - signatureSize) @@ -46,15 +50,62 @@ object MessageDecrypter { val verificationData = (plaintext + senderED25519PublicKey + recipientX25519PublicKey) try { val isValid = sodium.cryptoSignVerifyDetached(signature, verificationData, verificationData.size, senderED25519PublicKey) - if (!isValid) { throw MessageReceiver.Error.InvalidSignature } + if (!isValid) { throw Error.InvalidSignature } } catch (exception: Exception) { Log.d("Loki", "Couldn't verify message signature due to error: $exception.") - throw MessageReceiver.Error.InvalidSignature + throw Error.InvalidSignature } // 4. ) Get the sender's X25519 public key val senderX25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) sodium.convertPublicKeyEd25519ToCurve25519(senderX25519PublicKey, senderED25519PublicKey) - return Pair(plaintext, "05" + senderX25519PublicKey.toHexString()) + val id = SessionId(IdPrefix.STANDARD, senderX25519PublicKey) + return Pair(plaintext, id.hexString) + } + + fun decryptBlinded( + message: ByteArray, + isOutgoing: Boolean, + otherBlindedPublicKey: String, + serverPublicKey: String + ): Pair { + if (message.size < Box.NONCEBYTES + 2) throw Error.DecryptionFailed + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair + val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.DecryptionFailed + // Calculate the shared encryption key, receiving from A to B + val otherKeyBytes = Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) + val kA = if (isOutgoing) blindedKeyPair.publicKey.asBytes else otherKeyBytes + val decryptionKey = SodiumUtilities.sharedBlindedEncryptionKey( + userEdKeyPair.secretKey.asBytes, + otherKeyBytes, + kA, + if (isOutgoing) otherKeyBytes else blindedKeyPair.publicKey.asBytes + ) ?: throw Error.DecryptionFailed + + // v, ct, nc = data[0], data[1:-24], data[-24:size] + val version = message.first().toInt() + if (version != 0) throw Error.DecryptionFailed + val ciphertext = message.drop(1).dropLast(Box.NONCEBYTES).toByteArray() + val nonce = message.takeLast(Box.NONCEBYTES).toByteArray() + + // Decrypt the message + val innerBytes = SodiumUtilities.decrypt(ciphertext, decryptionKey, nonce) ?: throw Error.DecryptionFailed + if (innerBytes.size < Sign.PUBLICKEYBYTES) throw Error.DecryptionFailed + + // Split up: the last 32 bytes are the sender's *unblinded* ed25519 key + val plaintextEndIndex = innerBytes.size - Sign.PUBLICKEYBYTES + val plaintext = innerBytes.slice(0 until plaintextEndIndex).toByteArray() + val senderEdPublicKey = innerBytes.slice((plaintextEndIndex until innerBytes.size)).toByteArray() + + // Verify that the inner senderEdPublicKey (A) yields the same outer kA we got with the message + val blindingFactor = SodiumUtilities.generateBlindingFactor(serverPublicKey) ?: throw Error.DecryptionFailed + val sharedSecret = SodiumUtilities.combineKeys(blindingFactor, senderEdPublicKey) ?: throw Error.DecryptionFailed + if (!kA.contentEquals(sharedSecret)) throw Error.InvalidSignature + + // Get the sender's X25519 public key + val senderX25519PublicKey = SodiumUtilities.toX25519(senderEdPublicKey) ?: throw Error.InvalidSignature + + val id = SessionId(IdPrefix.STANDARD, senderX25519PublicKey) + return Pair(plaintext, id.hexString) } } \ No newline at end of file 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 52485acd45..24a620f8d4 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 @@ -6,9 +6,11 @@ import com.goterl.lazysodium.interfaces.Box import com.goterl.lazysodium.interfaces.Sign import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageSender.Error +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageEncrypter { @@ -24,7 +26,7 @@ object MessageEncrypter { */ internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair - val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) + val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) val verificationData = plaintext + userED25519KeyPair.publicKey.asBytes + recipientX25519PublicKey val signature = ByteArray(Sign.BYTES) @@ -46,4 +48,33 @@ object MessageEncrypter { return ciphertext } + internal fun encryptBlinded( + plaintext: ByteArray, + recipientBlindedId: String, + serverPublicKey: String + ): ByteArray { + if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed + val userEdKeyPair = + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair + val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.SigningFailed + val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded()) + + // Calculate the shared encryption key, sending from A to B + val encryptionKey = SodiumUtilities.sharedBlindedEncryptionKey( + userEdKeyPair.secretKey.asBytes, + recipientBlindedPublicKey, + blindedKeyPair.publicKey.asBytes, + recipientBlindedPublicKey + ) ?: throw Error.SigningFailed + + // Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) + val message = plaintext + userEdKeyPair.publicKey.asBytes + + // Encrypt using xchacha20-poly1305 + val nonce = sodium.nonce(24) + val ciphertext = SodiumUtilities.encrypt(message, encryptionKey, nonce) ?: throw Error.EncryptionFailed + // data = b'\x00' + ciphertext + nonce + return byteArrayOf(0.toByte()) + ciphertext + nonce + } + } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index f0ce0265d0..6a38a551f8 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -12,8 +12,11 @@ import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log object MessageReceiver { @@ -31,6 +34,7 @@ object MessageReceiver { object SelfSend: Error("Message addressed at self.") object InvalidGroupPublicKey: Error("Invalid group public key.") object NoGroupKeyPair: Error("Missing group key pair.") + object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") internal val isRetryable: Boolean = when (this) { is DuplicateMessage, is InvalidMessage, is UnknownMessage, @@ -40,7 +44,13 @@ object MessageReceiver { } } - internal fun parse(data: ByteArray, openGroupServerID: Long?): Pair { + internal fun parse( + data: ByteArray, + openGroupServerID: Long?, + isOutgoing: Boolean? = null, + otherBlindedPublicKey: String? = null, + openGroupPublicKey: String? = null, + ): Pair { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() val isOpenGroupMessage = (openGroupServerID != null) @@ -59,10 +69,23 @@ object MessageReceiver { } else { when (envelope.type) { SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { - val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() - val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), userX25519KeyPair) - plaintext = decryptionResult.first - sender = decryptionResult.second + if (IdPrefix.fromValue(envelope.source) == IdPrefix.BLINDED) { + openGroupPublicKey ?: throw Error.InvalidGroupPublicKey + otherBlindedPublicKey ?: throw Error.DecryptionFailed + val decryptionResult = MessageDecrypter.decryptBlinded( + ciphertext.toByteArray(), + isOutgoing ?: false, + otherBlindedPublicKey, + openGroupPublicKey + ) + plaintext = decryptionResult.first + sender = decryptionResult.second + } else { + val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() + val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), userX25519KeyPair) + plaintext = decryptionResult.first + sender = decryptionResult.second + } } SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE -> { val hexEncodedGroupPublicKey = envelope.source @@ -118,8 +141,9 @@ object MessageReceiver { VisibleMessage.fromProto(proto) ?: run { throw Error.UnknownMessage } + val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } // Ignore self send if needed - if (!message.isSelfSendValid && sender == userPublicKey) { + if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) { throw Error.SelfSend } // Guard against control messages in open groups diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 2209c7e40e..d17fbe6aff 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving +import com.goterl.lazysodium.utils.KeyPair import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration @@ -17,9 +18,11 @@ import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 -import org.session.libsession.messaging.open_groups.OpenGroupMessageV2 +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.RawResponsePromise import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeMessage @@ -30,10 +33,12 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.hasNamespaces import org.session.libsignal.utilities.hexEncodedPublicKey +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview @@ -62,10 +67,10 @@ object MessageSender { // Convenience fun send(message: Message, destination: Destination): Promise { - if (destination is Destination.OpenGroupV2) { - return sendToOpenGroupDestination(destination, message) + return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { + sendToOpenGroupDestination(destination, message) } else { - return sendToSnodeDestination(destination, message) + sendToSnodeDestination(destination, message) } } @@ -96,7 +101,7 @@ object MessageSender { when (destination) { is Destination.Contact -> message.recipient = destination.publicKey is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey - is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be an open group.") + else -> throw IllegalStateException("Destination should not be an open group.") } // Validate the message if (!message.isValid()) { throw Error.InvalidMessage } @@ -127,14 +132,13 @@ object MessageSender { // Serialize the protobuf val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) // Encrypt the serialized protobuf - val ciphertext: ByteArray - when (destination) { - is Destination.Contact -> ciphertext = MessageEncrypter.encrypt(plaintext, destination.publicKey) + val ciphertext = when (destination) { + is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) is Destination.ClosedGroup -> { val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! - ciphertext = MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) + MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) } - is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.") + else -> throw IllegalStateException("Destination should not be open group.") } // Wrap the result val kind: SignalServiceProtos.Envelope.Type @@ -157,7 +161,7 @@ object MessageSender { kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE senderPublicKey = destination.groupPublicKey } - is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.") + else -> throw IllegalStateException("Destination should not be open group.") } val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) // Send the result @@ -174,7 +178,7 @@ object MessageSender { namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> var isSuccess = false val promiseCount = promises.size - var errorCount = AtomicInteger(0) + val errorCount = AtomicInteger(0) promises.forEach { promise: RawResponsePromise -> promise.success { if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds @@ -217,42 +221,67 @@ object MessageSender { if (message.sentTimestamp == null) { message.sentTimestamp = System.currentTimeMillis() } - message.sender = storage.getUserPublicKey() + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! + var serverCapabilities = listOf() + var blindedPublicKey: ByteArray? = null + when(destination) { + is Destination.OpenGroup -> { + serverCapabilities = storage.getServerCapabilities(destination.server) + storage.getOpenGroup(destination.roomToken, destination.server)?.let { + blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + } + } + is Destination.OpenGroupInbox -> { + serverCapabilities = storage.getServerCapabilities(destination.server) + blindedPublicKey = SodiumUtilities.blindedKeyPair(destination.serverPublicKey, userEdKeyPair)?.publicKey?.asBytes + } + is Destination.LegacyOpenGroup -> { + serverCapabilities = storage.getServerCapabilities(destination.server) + storage.getOpenGroup(destination.roomToken, destination.server)?.let { + blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + } + } + else -> {} + } + val messageSender = if (serverCapabilities.contains("blind") && blindedPublicKey != null) { + SessionId(IdPrefix.BLINDED, blindedPublicKey!!).hexString + } else { + SessionId(IdPrefix.UN_BLINDED, userEdKeyPair.publicKey.asBytes).hexString + } + message.sender = messageSender // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error) deferred.reject(error) } try { + // Attach the user's profile if needed + if (message is VisibleMessage) { + val displayName = storage.getUserDisplayName()!! + val profileKey = storage.getUserProfileKey() + val profilePictureUrl = storage.getUserProfilePictureURL() + if (profileKey != null && profilePictureUrl != null) { + message.profile = Profile(displayName, profileKey, profilePictureUrl) + } else { + message.profile = Profile(displayName) + } + } when (destination) { - is Destination.Contact, is Destination.ClosedGroup -> throw IllegalStateException("Invalid destination.") - is Destination.OpenGroupV2 -> { - message.recipient = "${destination.server}.${destination.room}" - val server = destination.server - val room = destination.room - // Attach the user's profile if needed - if (message is VisibleMessage) { - val displayName = storage.getUserDisplayName()!! - val profileKey = storage.getUserProfileKey() - val profilePictureUrl = storage.getUserProfilePictureURL() - if (profileKey != null && profilePictureUrl != null) { - message.profile = Profile(displayName, profileKey, profilePictureUrl) - } else { - message.profile = Profile(displayName) - } - } + is Destination.OpenGroup -> { + val whisperMods = if (destination.whisperTo.isNullOrEmpty() && destination.whisperMods) "mods" else null + message.recipient = "${destination.server}.${destination.roomToken}.${destination.whisperTo}.$whisperMods" // Validate the message if (message !is VisibleMessage || !message.isValid()) { throw Error.InvalidMessage } - val proto = message.toProto()!! - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) - val openGroupMessage = OpenGroupMessageV2( + val messageBody = message.toProto()?.toByteArray()!! + val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) + val openGroupMessage = OpenGroupMessage( sender = message.sender, sentTimestamp = message.sentTimestamp!!, base64EncodedData = Base64.encodeBytes(plaintext), ) - OpenGroupAPIV2.send(openGroupMessage,room,server).success { + OpenGroupApi.sendMessage(openGroupMessage, destination.roomToken, destination.server, destination.whisperTo, destination.whisperMods, destination.fileIds).success { message.openGroupServerMessageID = it.serverID handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp) deferred.resolve(Unit) @@ -260,6 +289,29 @@ object MessageSender { handleFailure(it) } } + is Destination.OpenGroupInbox -> { + message.recipient = destination.blindedPublicKey + // Validate the message + if (message !is VisibleMessage || !message.isValid()) { + throw Error.InvalidMessage + } + val messageBody = message.toProto()?.toByteArray()!! + val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) + val ciphertext = MessageEncrypter.encryptBlinded( + plaintext, + destination.blindedPublicKey, + destination.serverPublicKey + ) + val base64EncodedData = Base64.encodeBytes(ciphertext) + OpenGroupApi.sendDirectMessage(base64EncodedData, destination.blindedPublicKey, destination.server).success { + message.openGroupServerMessageID = it.id + handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = TimeUnit.SECONDS.toMillis(it.postedAt)) + deferred.resolve(Unit) + }.fail { + handleFailure(it) + } + } + else -> throw IllegalStateException("Invalid destination.") } } catch (exception: Exception) { handleFailure(exception) @@ -273,7 +325,7 @@ object MessageSender { val userPublicKey = storage.getUserPublicKey()!! // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) - storage.getMessageIdInDatabase(message.sentTimestamp!!, message.sender?:userPublicKey)?.let { messageID -> + storage.getMessageIdInDatabase(message.sentTimestamp!!, userPublicKey)?.let { messageID -> if (openGroupSentTimestamp != -1L && message is VisibleMessage) { storage.addReceivedMessageTimestamp(openGroupSentTimestamp) storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!) @@ -286,19 +338,19 @@ object MessageSender { storage.setMessageServerHash(messageID, it) } // Track the open group server message ID - if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) { - val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray()) + if (message.openGroupServerMessageID != null && destination is Destination.LegacyOpenGroup) { + val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.roomToken}".toByteArray()) val threadID = storage.getThreadId(Address.fromSerialized(encoded)) if (threadID != null && threadID >= 0) { storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) } } // Mark the message as sent - storage.markAsSent(message.sentTimestamp!!, message.sender?:userPublicKey) - storage.markUnidentified(message.sentTimestamp!!, message.sender?:userPublicKey) + storage.markAsSent(message.sentTimestamp!!, userPublicKey) + storage.markUnidentified(message.sentTimestamp!!, userPublicKey) // Start the disappearing messages timer if needed if (message is VisibleMessage && !isSyncMessage) { - SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, message.sender?:userPublicKey) + SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, userPublicKey) } } // Sync the message if: diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index dfd63b2090..906e201a8a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -21,7 +21,7 @@ import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.hexEncodedPublicKey -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.Log @@ -290,7 +290,7 @@ fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, ta fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKeyPair, targetMembers: Collection, targetUser: String? = null, force: Boolean = true): Promise? { val destination = targetUser ?: GroupUtil.doubleEncodeGroupID(groupPublicKey) val proto = SignalServiceProtos.KeyPair.newBuilder() - proto.publicKey = ByteString.copyFrom(newKeyPair.publicKey.serialize().removing05PrefixIfNeeded()) + proto.publicKey = ByteString.copyFrom(newKeyPair.publicKey.serialize().removingIdPrefixIfNeeded()) proto.privateKey = ByteString.copyFrom(newKeyPair.privateKey.serialize()) val plaintext = proto.build().toByteArray() val wrappers = targetMembers.map { publicKey -> @@ -326,7 +326,7 @@ fun MessageSender.sendLatestEncryptionKeyPair(publicKey: String, groupPublicKey: ?: storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return // Send it val proto = SignalServiceProtos.KeyPair.newBuilder() - proto.publicKey = ByteString.copyFrom(encryptionKeyPair.publicKey.serialize().removing05PrefixIfNeeded()) + proto.publicKey = ByteString.copyFrom(encryptionKeyPair.publicKey.serialize().removingIdPrefixIfNeeded()) proto.privateKey = ByteString.copyFrom(encryptionKeyPair.privateKey.serialize()) val plaintext = proto.build().toByteArray() val ciphertext = MessageEncrypter.encrypt(plaintext, publicKey) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index b824c55226..dd39a85f53 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -23,6 +23,8 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -38,9 +40,10 @@ import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional -import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString import java.security.MessageDigest import java.util.LinkedList @@ -153,7 +156,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer) } } - val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL } + val allV2OpenGroups = storage.getAllOpenGroups().map { it.value.joinURL } for (openGroup in message.openGroups) { if (allV2OpenGroups.contains(openGroup)) continue Log.d("OpenGroup", "All open groups doesn't contain $openGroup") @@ -216,8 +219,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, // Get or create thread // FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet // exist. This is intentional, but it's very non-obvious. - val threadID = storage.getOrCreateThreadIdFor(message.syncTarget - ?: messageSender!!, message.groupPublicKey, openGroupID) + val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID) if (threadID < 0) { // Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread throw MessageReceiver.Error.NoThread @@ -226,7 +228,9 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false) if (runProfileUpdate) { val profile = message.profile - if (profile != null && userPublicKey != messageSender) { + val isUserBlindedSender = messageSender == storage.getOpenGroup(threadID)?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId( + IdPrefix.BLINDED, it.publicKey.asBytes).hexString } + if (profile != null && userPublicKey != messageSender && !isUserBlindedSender) { val profileManager = SSKEnvironment.shared.profileManager val name = profile.displayName!! if (name.isNotEmpty()) { @@ -395,7 +399,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr val plaintext = MessageDecrypter.decrypt(encryptedKeyPair, userKeyPair).first // Parse it val proto = SignalServiceProtos.KeyPair.parseFrom(plaintext) - val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removing05PrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray())) + val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removingIdPrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray())) // Store it if needed val closedGroupEncryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(groupPublicKey) if (closedGroupEncryptionKeyPairs.contains(keyPair)) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt index 64949e06e8..f793cd6e4b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt @@ -7,6 +7,7 @@ import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.Version import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.JsonUtil @@ -38,12 +39,12 @@ object PushNotificationAPI { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> - val code = json["code"] as? Int + OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> + val code = response.info["code"] as? Int if (code != null && code != 0) { TextSecurePreferences.setIsUsingFCM(context, false) } else { - Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.") + Log.d("Loki", "Couldn't disable FCM due to error: ${response.info["message"] as? String ?: "null"}.") } }.fail { exception -> Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.") @@ -66,14 +67,14 @@ object PushNotificationAPI { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> - val code = json["code"] as? Int + OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> + val code = response.info["code"] as? Int if (code != null && code != 0) { TextSecurePreferences.setIsUsingFCM(context, true) TextSecurePreferences.setFCMToken(context, token) TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis()) } else { - Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.") + Log.d("Loki", "Couldn't register for FCM due to error: ${response.info["message"] as? String ?: "null"}.") } }.fail { exception -> Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.") @@ -93,10 +94,10 @@ object PushNotificationAPI { val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body) retryIfNeeded(maxRetryCount) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> - val code = json["code"] as? Int + OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> + val code = response.info["code"] as? Int if (code == null || code == 0) { - Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.") + Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${response.info["message"] as? String ?: "null"}.") } }.fail { exception -> Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt new file mode 100644 index 0000000000..ced4cbbba2 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -0,0 +1,274 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import com.google.protobuf.ByteString +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.map +import org.session.libsession.messaging.BlindedIdMapping +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.jobs.OpenGroupDeleteJob +import org.session.libsession.messaging.jobs.TrimThreadJob +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.Endpoint +import org.session.libsession.messaging.open_groups.GroupMember +import org.session.libsession.messaging.open_groups.GroupMemberRole +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.handle +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.successBackground +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class OpenGroupPoller(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(isPostCapabilitiesRetry: Boolean = false): Promise { + val storage = MessagingModuleConfiguration.shared.storage + val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room } + rooms.forEach { downloadGroupAvatarIfNeeded(it) } + return OpenGroupApi.poll(rooms, server).successBackground { responses -> + responses.filterNot { it.body == null }.forEach { response -> + when (response.endpoint) { + is Endpoint.Capabilities -> { + handleCapabilities(server, response.body as OpenGroupApi.Capabilities) + } + is Endpoint.RoomPollInfo -> { + handleRoomPollInfo(server, response.endpoint.roomToken, response.body as OpenGroupApi.RoomPollInfo) + } + is Endpoint.RoomMessagesRecent -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.RoomMessagesSince -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.Inbox, is Endpoint.InboxSince -> { + handleDirectMessages(server, false, response.body as List) + } + is Endpoint.Outbox, is Endpoint.OutboxSince -> { + handleDirectMessages(server, true, response.body as List) + } + } + if (secondToLastJob == null && !isCaughtUp) { + isCaughtUp = true + } + } + executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) + }.fail { + updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, it) + }.map { } + } + + private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) { + if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) { + if (!isPostCapabilitiesRetry) { + OpenGroupApi.getCapabilities(server).map { + handleCapabilities(server, it) + } + executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS) + } + } else { + executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) + } + } + + private fun handleCapabilities(server: String, capabilities: OpenGroupApi.Capabilities) { + val storage = MessagingModuleConfiguration.shared.storage + storage.setServerCapabilities(server, capabilities.capabilities) + } + + private fun handleRoomPollInfo( + server: String, + roomToken: String, + pollInfo: OpenGroupApi.RoomPollInfo + ) { + val storage = MessagingModuleConfiguration.shared.storage + val groupId = "$server.$roomToken" + + val existingOpenGroup = storage.getOpenGroup(roomToken, server) + val publicKey = existingOpenGroup?.publicKey ?: return + val openGroup = OpenGroup( + server = server, + room = pollInfo.token, + name = pollInfo.details?.name ?: "", + infoUpdates = pollInfo.details?.infoUpdates ?: 0, + publicKey = publicKey, + ) + // - Open Group changes + storage.updateOpenGroup(openGroup) + + // - User Count + storage.setUserCount(roomToken, server, pollInfo.activeUsers) + + // - Moderators + pollInfo.details?.moderators?.forEach { + storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.MODERATOR)) + } + // - Admins + pollInfo.details?.admins?.forEach { + storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.ADMIN)) + } + } + + private fun handleMessages( + server: String, + roomToken: String, + messages: List + ) { + val openGroupId = "$server.$roomToken" + val sortedMessages = messages.sortedBy { it.seqno } + sortedMessages.maxOfOrNull { it.seqno }?.let { + MessagingModuleConfiguration.shared.storage.setLastMessageServerID(roomToken, server, it) + } + val (deletions, additions) = sortedMessages.partition { it.deleted || it.data.isNullOrBlank() } + handleNewMessages(openGroupId, additions.map { + OpenGroupMessage( + serverID = it.id, + sender = it.sessionId, + sentTimestamp = (it.posted * 1000).toLong(), + base64EncodedData = it.data!!, + base64EncodedSignature = it.signature + ) + }) + handleDeletedMessages(openGroupId, deletions.map { it.id }) + } + + private fun handleDirectMessages( + server: String, + fromOutbox: Boolean, + messages: List + ) { + if (messages.isEmpty()) return + val storage = MessagingModuleConfiguration.shared.storage + val serverPublicKey = storage.getOpenGroupPublicKey(server)!! + val sortedMessages = messages.sortedBy { it.id } + val lastMessageId = sortedMessages.last().id + val mappingCache = mutableMapOf() + if (fromOutbox) { + storage.setLastOutboxMessageId(server, lastMessageId) + } else { + storage.setLastInboxMessageId(server, lastMessageId) + } + sortedMessages.forEach { + val encodedMessage = Base64.decode(it.message) + val envelope = SignalServiceProtos.Envelope.newBuilder() + .setTimestamp(TimeUnit.SECONDS.toMillis(it.postedAt)) + .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) + .setContent(ByteString.copyFrom(encodedMessage)) + .setSource(it.sender) + .build() + try { + val (message, proto) = MessageReceiver.parse( + envelope.toByteArray(), + null, + fromOutbox, + if (fromOutbox) it.recipient else it.sender, + serverPublicKey + ) + if (fromOutbox) { + val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping( + it.recipient, + server, + serverPublicKey, + true + ) + val syncTarget = mapping.sessionId ?: it.recipient + if (message is VisibleMessage) { + message.syncTarget = syncTarget + } else if (message is ExpirationTimerUpdate) { + message.syncTarget = syncTarget + } + mappingCache[it.recipient] = mapping + } + MessageReceiver.handle(message, proto, null) + } catch (e: Exception) { + Log.e("Loki", "Couldn't handle direct message", e) + } + } + } + + private fun handleNewMessages(openGroupID: String, messages: List) { + 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(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> + val parameters = list.map { (message, serverId) -> + MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId) + } + JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID)) + } + + if (envelopes.isNotEmpty()) { + JobQueue.shared.add(TrimThreadJob(threadId,openGroupID)) + } + } + + private fun handleDeletedMessages(openGroupID: String, serverIds: List) { + val storage = MessagingModuleConfiguration.shared.storage + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return + + if (serverIds.isNotEmpty()) { + val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupID) + JobQueue.shared.add(deleteJob) + } + } + + 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)) + } + } + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt deleted file mode 100644 index 55a4db01a1..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt +++ /dev/null @@ -1,132 +0,0 @@ -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.jobs.OpenGroupDeleteJob -import org.session.libsession.messaging.jobs.TrimThreadJob -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 -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 OpenGroupPollerV2(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(isBackgroundPoll: Boolean = false): Promise { - val storage = MessagingModuleConfiguration.shared.storage - val rooms = storage.getAllV2OpenGroups().values.filter { it.server == server }.map { it.room } - rooms.forEach { downloadGroupAvatarIfNeeded(it) } - return OpenGroupAPIV2.compactPoll(rooms, server).successBackground { responses -> - responses.forEach { (room, response) -> - val openGroupID = "$server.$room" - handleNewMessages(room, openGroupID, response.messages, isBackgroundPoll) - handleDeletedMessages(room, openGroupID, response.deletions) - if (secondToLastJob == null && !isCaughtUp) { - isCaughtUp = true - } - } - }.always { - executorService?.schedule(this@OpenGroupPollerV2::poll, pollInterval, TimeUnit.MILLISECONDS) - }.map { } - } - - private fun handleNewMessages(room: String, openGroupID: String, messages: List, isBackgroundPoll: Boolean) { - 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(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> - val parameters = list.map { (message, serverId) -> - MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId) - } - JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID)) - } - - if (envelopes.isNotEmpty()) { - JobQueue.shared.add(TrimThreadJob(threadId,openGroupID)) - } - - val indicatedMax = messages.mapNotNull { it.serverID }.maxOrNull() ?: 0 - val currentLastMessageServerID = storage.getLastMessageServerID(room, server) ?: 0 - val actualMax = max(indicatedMax, currentLastMessageServerID) - if (actualMax > 0 && indicatedMax > currentLastMessageServerID) { - storage.setLastMessageServerID(room, server, actualMax) - } - } - - private fun handleDeletedMessages(room: String, openGroupID: String, deletions: List) { - val storage = MessagingModuleConfiguration.shared.storage - val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) - val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return - - val serverIds = deletions.map { deletion -> - deletion.deletedMessageServerID - } - - if (serverIds.isNotEmpty()) { - val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupID) - JobQueue.shared.add(deleteJob) - } - - 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)) - } - } - } -} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt new file mode 100644 index 0000000000..e4dded5a78 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -0,0 +1,251 @@ +package org.session.libsession.messaging.utilities + +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.goterl.lazysodium.interfaces.AEAD +import com.goterl.lazysodium.interfaces.GenericHash +import com.goterl.lazysodium.interfaces.Hash +import com.goterl.lazysodium.utils.Key +import com.goterl.lazysodium.utils.KeyPair +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.toHexString +import org.whispersystems.curve25519.Curve25519 +import kotlin.experimental.xor + +object SodiumUtilities { + private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + private val curve by lazy { Curve25519.getInstance(Curve25519.BEST) } + + private const val SCALAR_LENGTH: Int = 32 // crypto_core_ed25519_scalarbytes + private const val NO_CLAMP_LENGTH: Int = 32 // crypto_scalarmult_ed25519_bytes + private const val SCALAR_MULT_LENGTH: Int = 32 // crypto_scalarmult_bytes + private const val PUBLIC_KEY_LENGTH: Int = 32 // crypto_scalarmult_bytes + private const val SECRET_KEY_LENGTH: Int = 64 //crypto_sign_secretkeybytes + + /* 64-byte blake2b hash then reduce to get the blinding factor */ + fun generateBlindingFactor(serverPublicKey: String): ByteArray? { + // k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey) + if (serverPubKeyData.size != PUBLIC_KEY_LENGTH) return null + val serverPubKeyHash = ByteArray(GenericHash.BLAKE2B_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(SCALAR_LENGTH) + 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) + */ + fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? { + // a = s.to_curve25519_private_key().encode() + val aBytes = ByteArray(SCALAR_MULT_LENGTH) + 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 != PUBLIC_KEY_LENGTH || edKeyPair.secretKey.asBytes.size != SECRET_KEY_LENGTH) return null + val kBytes = generateBlindingFactor(serverPublicKey) ?: return null + val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes) ?: return null + // Generate the blinded key pair `ka`, `kA` + val kaBytes = ByteArray(SECRET_KEY_LENGTH) + sodium.cryptoCoreEd25519ScalarMul(kaBytes, kBytes, aBytes) + if (kaBytes.all { it.toInt() == 0 }) return null + + val kABytes = ByteArray(PUBLIC_KEY_LENGTH) + return if (sodium.cryptoScalarMultEd25519BaseNoClamp(kABytes, kaBytes)) { + 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, + blindedSecretKey: ByteArray, /*ka*/ + blindedPublicKey: ByteArray /*kA*/ + ): ByteArray? { + // H_rh = sha512(s.encode()).digest()[32:] + val digest = ByteArray(Hash.SHA512_BYTES) + val h_rh = if (sodium.cryptoHashSha512(digest, secretKey, secretKey.size.toLong())) { + digest.takeLast(32).toByteArray() + } else return null + + // r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) + val rHash = sha512Multipart(listOf(h_rh, blindedPublicKey, message)) ?: return null + val r = ByteArray(SCALAR_LENGTH) + sodium.cryptoCoreEd25519ScalarReduce(r, rHash) + if (r.all { it.toInt() == 0 }) return null + + // sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) + val sig_R = ByteArray(NO_CLAMP_LENGTH) + if (!sodium.cryptoScalarMultEd25519BaseNoClamp(sig_R, r)) return null + + // HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) + val hRamHash = sha512Multipart(listOf(sig_R, blindedPublicKey, message)) ?: return null + val hRam = ByteArray(SCALAR_LENGTH) + 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(SCALAR_LENGTH) + val sig_s = ByteArray(SCALAR_LENGTH) + sodium.cryptoCoreEd25519ScalarMul(sig_sMul, hRam, blindedSecretKey) + if (sig_sMul.any { it.toInt() != 0 }) { + sodium.cryptoCoreEd25519ScalarAdd(sig_s, r, sig_sMul) + if (sig_s.all { it.toInt() == 0 }) return null + } else return null + + return sig_R + sig_s + } + + private fun sha512Multipart(parts: List): ByteArray? { + val state = Hash.State512() + sodium.cryptoHashSha512Init(state) + parts.forEach { + sodium.cryptoHashSha512Update(state, it, it.size.toLong()) + } + val finalHash = ByteArray(Hash.SHA512_BYTES) + return if (sodium.cryptoHashSha512Final(state, finalHash)) { + finalHash + } else null + } + + /* Combines two keys (`kA`) */ + fun combineKeys(lhsKey: ByteArray, rhsKey: ByteArray): ByteArray? { + val kA = ByteArray(NO_CLAMP_LENGTH) + return if (sodium.cryptoScalarMultEd25519NoClamp(kA, lhsKey, rhsKey)) { + kA + } else null + } + + /* + 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 = curve.convertToEd25519PublicKey(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().xor(128.toByte())).toByteArray() + return SessionId(IdPrefix.BLINDED, pk1).publicKey == blindedId.publicKey || + SessionId(IdPrefix.BLINDED, pk2).publicKey == blindedId.publicKey + } + + fun encrypt(message: ByteArray, secretKey: ByteArray, nonce: ByteArray, additionalData: ByteArray? = null): ByteArray? { + val authenticatedCipherText = ByteArray(message.size + AEAD.CHACHA20POLY1305_ABYTES) + return if (sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( + authenticatedCipherText, + longArrayOf(0), + message, + message.size.toLong(), + additionalData, + (additionalData?.size ?: 0).toLong(), + null, + nonce, + secretKey + ) + ) { + authenticatedCipherText + } else null + } + + fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? { + val plaintextSize = ciphertext.size - AEAD.CHACHA20POLY1305_ABYTES + val plaintext = ByteArray(plaintextSize) + return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( + plaintext, + longArrayOf(plaintextSize.toLong()), + null, + ciphertext, + ciphertext.size.toLong(), + null, + 0L, + nonce, + decryptionKey + ) + ) { + plaintext + } else null + } + + fun toX25519(ed25519PublicKey: ByteArray): ByteArray? { + val x25519PublicKey = ByteArray(PUBLIC_KEY_LENGTH) + return if (sodium.convertPublicKeyEd25519ToCurve25519(x25519PublicKey, ed25519PublicKey)) { + x25519PublicKey + } else null + } + +} + +class SessionId { + var prefix: IdPrefix? + var publicKey: String + + constructor(id: String) { + prefix = IdPrefix.fromValue(id) + publicKey = id.drop(2) + } + + constructor(prefix: IdPrefix, publicKey: ByteArray) { + this.prefix = prefix + this.publicKey = publicKey.toHexString() + } + + val hexString + get() = prefix?.value + publicKey +} + 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 67404dedee..b1d45b7bd0 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -1,12 +1,13 @@ package org.session.libsession.snode +import nl.komponents.kovenant.Deferred import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map import okhttp3.Request -import org.session.libsession.messaging.file_server.FileServerAPIV2 +import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsession.utilities.getBodyForOnionRequest @@ -76,7 +77,8 @@ object OnionRequestAPI { const val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path // endregion - class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String) + class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination) + open class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String) : Exception("HTTP request failed at destination ($destination) with status code $statusCode.") class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") @@ -100,7 +102,8 @@ object OnionRequestAPI { ThreadUtils.queue { // No need to block the shared context for this val url = "${snode.address}:${snode.port}/get_stats/v1" try { - val json = HTTP.execute(HTTP.Verb.GET, url, 3) + val response = HTTP.execute(HTTP.Verb.GET, url, 3).decodeToString() + val json = JsonUtil.fromJson(response, Map::class.java) val version = json["version"] as? String if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue } if (version >= "2.0.7") { @@ -207,26 +210,30 @@ object OnionRequestAPI { } OnionRequestAPI.guardSnodes = guardSnodes fun getPath(paths: List): Path { - if (snodeToExclude != null) { - return paths.filter { !it.contains(snodeToExclude) }.getRandomElement() + return if (snodeToExclude != null) { + paths.filter { !it.contains(snodeToExclude) }.getRandomElement() } else { - return paths.getRandomElement() + paths.getRandomElement() } } - if (paths.count() >= targetPathCount) { - return Promise.of(getPath(paths)) - } else if (paths.isNotEmpty()) { - if (paths.any { !it.contains(snodeToExclude) }) { - buildPaths(paths) // Re-build paths in the background + when { + paths.count() >= targetPathCount -> { return Promise.of(getPath(paths)) - } else { - return buildPaths(paths).map { newPaths -> - getPath(newPaths) + } + paths.isNotEmpty() -> { + return if (paths.any { !it.contains(snodeToExclude) }) { + buildPaths(paths) // Re-build paths in the background + Promise.of(getPath(paths)) + } else { + buildPaths(paths).map { newPaths -> + getPath(newPaths) + } } } - } else { - return buildPaths(listOf()).map { newPaths -> - getPath(newPaths) + else -> { + return buildPaths(listOf()).map { newPaths -> + getPath(newPaths) + } } } } @@ -268,7 +275,11 @@ object OnionRequestAPI { /** * Builds an onion around `payload` and returns the result. */ - private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise { + private fun buildOnionForDestination( + payload: ByteArray, + destination: Destination, + version: Version + ): Promise { lateinit var guardSnode: Snode lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination lateinit var encryptionResult: EncryptionResult @@ -279,19 +290,19 @@ object OnionRequestAPI { return getPath(snodeToExclude).bind { path -> guardSnode = path.first() // Encrypt in reverse order, i.e. the destination first - OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind { r -> + OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version).bind { r -> destinationSymmetricKey = r.symmetricKey // Recursively encrypt the layers of the onion (again in reverse order) encryptionResult = r @Suppress("NAME_SHADOWING") var path = path var rhs = destination fun addLayer(): Promise { - if (path.isEmpty()) { - return Promise.of(encryptionResult) + return if (path.isEmpty()) { + Promise.of(encryptionResult) } else { val lhs = Destination.Snode(path.last()) path = path.dropLast(1) - return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r -> + OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r -> encryptionResult = r rhs = lhs addLayer() @@ -306,16 +317,20 @@ object OnionRequestAPI { /** * Sends an onion request to `destination`. Builds new paths as needed. */ - private fun sendOnionRequest(destination: Destination, payload: Map<*, *>): Promise, Exception> { - val deferred = deferred, Exception>() + private fun sendOnionRequest( + destination: Destination, + payload: ByteArray, + version: Version + ): Promise { + val deferred = deferred() var guardSnode: Snode? = null - buildOnionForDestination(payload, destination).success { result -> + buildOnionForDestination(payload, destination, version).success { result -> guardSnode = result.guardSnode val nonNullGuardSnode = result.guardSnode val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" val finalEncryptionResult = result.finalEncryptionResult val onion = finalEncryptionResult.ciphertext - if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPIV2.maxFileSize.toDouble()) { + if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.maxFileSize.toDouble()) { Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.") } @Suppress("NAME_SHADOWING") val parameters = mapOf( @@ -330,65 +345,8 @@ object OnionRequestAPI { val destinationSymmetricKey = result.destinationSymmetricKey ThreadUtils.queue { try { - val json = HTTP.execute(HTTP.Verb.POST, url, body) - val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@queue deferred.reject(Exception("Invalid JSON")) - val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) - try { - val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey) - try { - @Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) - val statusCode = json["status_code"] as? Int ?: json["status"] as Int - if (statusCode == 406) { - @Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." ) - val exception = HTTPRequestFailedAtDestinationException(statusCode, body, destination.description) - return@queue deferred.reject(exception) - } else if (json["body"] != null) { - @Suppress("NAME_SHADOWING") val body: Map<*, *> - if (json["body"] is Map<*, *>) { - body = json["body"] as Map<*, *> - } else { - val bodyAsString = json["body"] as String - body = JsonUtil.fromJson(bodyAsString, Map::class.java) - } - if (body["t"] != null) { - val timestamp = body["t"] as Long - val offset = timestamp - Date().time - SnodeAPI.clockOffset = offset - } - if (body.containsKey("hf")) { - @Suppress("UNCHECKED_CAST") - val currentHf = body["hf"] as List - if (currentHf.size < 2) { - Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number") - } else { - val hf = currentHf[0] - val sf = currentHf[1] - val newForkInfo = ForkInfo(hf, sf) - if (newForkInfo > SnodeAPI.forkInfo) { - SnodeAPI.forkInfo = ForkInfo(hf,sf) - } else if (newForkInfo < SnodeAPI.forkInfo) { - Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}") - } - } - } - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException(statusCode, body, destination.description) - return@queue deferred.reject(exception) - } - deferred.resolve(body) - } else { - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException(statusCode, json, destination.description) - return@queue deferred.reject(exception) - } - deferred.resolve(json) - } - } catch (exception: Exception) { - deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}.")) - } - } catch (exception: Exception) { - deferred.reject(exception) - } + val response = HTTP.execute(HTTP.Verb.POST, url, body) + handleResponse(response, destinationSymmetricKey, destination, version, deferred) } catch (exception: Exception) { deferred.reject(exception) } @@ -459,9 +417,19 @@ 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, Exception> { - val payload = mapOf( "method" to method.rawValue, "params" to parameters ) - return sendOnionRequest(Destination.Snode(snode), payload).recover { exception -> + internal fun sendOnionRequest( + method: Snode.Method, + parameters: Map<*, *>, + snode: Snode, + version: Version, + publicKey: String? = null + ): Promise { + val payload = mapOf( + "method" to method.rawValue, + "params" to parameters + ) + val payloadData = JsonUtil.toJson(payload).toByteArray() + return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> val error = when (exception) { is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) @@ -477,27 +445,228 @@ object OnionRequestAPI { * * `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance. */ - fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc"): Promise, Exception> { - val headers = request.getHeadersForOnionRequest() + fun sendOnionRequest( + request: Request, + server: String, + x25519PublicKey: String, + version: Version = Version.V4 + ): Promise { val url = request.url() - val urlAsString = url.toString() - val host = url.host() - val endpoint = when { - server.count() < urlAsString.count() -> urlAsString.substringAfter(server).removePrefix("/") - else -> "" - } - val body = request.getBodyForOnionRequest() ?: "null" - val payload = mapOf( - "body" to body, - "endpoint" to endpoint, - "method" to request.method(), - "headers" to headers - ) - val destination = Destination.Server(host, target, x25519PublicKey, url.scheme(), url.port()) - return sendOnionRequest(destination, payload).recover { exception -> - Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.") + val payload = generatePayload(request, server, version) + val destination = Destination.Server(url.host(), version.value, x25519PublicKey, url.scheme(), url.port()) + return sendOnionRequest(destination, payload, version).recover { exception -> + Log.d("Loki", "Couldn't reach server: $url due to error: $exception.") throw exception } } + + private fun generatePayload(request: Request, server: String, version: Version): ByteArray { + val headers = request.getHeadersForOnionRequest().toMutableMap() + val url = request.url() + val urlAsString = url.toString() + val body = request.getBodyForOnionRequest() ?: "null" + val endpoint = when { + server.count() < urlAsString.count() -> urlAsString.substringAfter(server) + else -> "" + } + return if (version == Version.V4) { + if (request.body() != null && + headers.keys.find { it.equals("Content-Type", true) } == null) { + headers["Content-Type"] = "application/json" + } + val requestPayload = mapOf( + "endpoint" to endpoint, + "method" to request.method(), + "headers" to headers + ) + val requestData = JsonUtil.toJson(requestPayload).toByteArray() + val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) + val suffixData = "e".toByteArray(Charsets.US_ASCII) + if (request.body() != null) { + val bodyData = body.toString().toByteArray() + val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) + prefixData + requestData + bodyLengthData + bodyData + suffixData + } else { + prefixData + requestData + suffixData + } + } else { + val payload = mapOf( + "body" to body, + "endpoint" to endpoint.removePrefix("/"), + "method" to request.method(), + "headers" to headers + ) + JsonUtil.toJson(payload).toByteArray() + } + } + + private fun handleResponse( + response: ByteArray, + destinationSymmetricKey: ByteArray, + destination: Destination, + version: Version, + deferred: Deferred + ) { + if (version == Version.V4) { + try { + if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response")) + // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into + // parts to properly process it + val plaintext = AESGCM.decrypt(response, destinationSymmetricKey) + if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response")) + val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } + val infoLenSlice = plaintext.slice(1 until infoSepIdx) + val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() + if (infoLenSlice.size <= 1 || infoLength == null) return deferred.reject(Exception("Invalid response")) + val infoStartIndex = "l$infoLength".length + 1 + val infoEndIndex = infoStartIndex + infoLength + val info = plaintext.slice(infoStartIndex until infoEndIndex) + val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java) + when (val statusCode = responseInfo["code"].toString().toInt()) { + // Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case) + 406, 425 -> { + @Suppress("NAME_SHADOWING") + val exception = HTTPRequestFailedAtDestinationException( + statusCode, + mapOf("result" to "Your clock is out of sync with the service node network."), + destination.description + ) + return deferred.reject(exception) + } + // Handle error status codes + !in 200..299 -> { + val responseBody = if (destination is Destination.Server && statusCode == 400) plaintext.getBody(infoLength, infoEndIndex) else null + val requireBlinding = "Invalid authentication: this server requires the use of blinded ids" + val exception = if (responseBody != null && responseBody.decodeToString() == requireBlinding) { + HTTPRequestFailedBlindingRequiredException(400, responseInfo, destination.description) + } else HTTPRequestFailedAtDestinationException( + statusCode, + responseInfo, + destination.description + ) + return deferred.reject(exception) + } + } + + val responseBody = plaintext.getBody(infoLength, infoEndIndex) + + // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo + if (responseBody.isEmpty()) { + return deferred.resolve(OnionResponse(responseInfo, null)) + } + return deferred.resolve(OnionResponse(responseInfo, responseBody)) + } catch (exception: Exception) { + deferred.reject(exception) + } + } else { + val json = try { + JsonUtil.fromJson(response, Map::class.java) + } catch (exception: Exception) { + mapOf( "result" to response.decodeToString()) + } + val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON")) + val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) + try { + val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey) + try { + @Suppress("NAME_SHADOWING") val json = + JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) + val statusCode = json["status_code"] as? Int ?: json["status"] as Int + when { + statusCode == 406 -> { + @Suppress("NAME_SHADOWING") + val body = + mapOf("result" to "Your clock is out of sync with the service node network.") + val exception = HTTPRequestFailedAtDestinationException( + statusCode, + body, + destination.description + ) + return deferred.reject(exception) + } + json["body"] != null -> { + @Suppress("NAME_SHADOWING") + val body = if (json["body"] is Map<*, *>) { + json["body"] as Map<*, *> + } else { + val bodyAsString = json["body"] as String + JsonUtil.fromJson(bodyAsString, Map::class.java) + } + if (body["t"] != null) { + val timestamp = body["t"] as Long + val offset = timestamp - Date().time + SnodeAPI.clockOffset = offset + } + if (body.containsKey("hf")) { + @Suppress("UNCHECKED_CAST") + val currentHf = body["hf"] as List + if (currentHf.size < 2) { + Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number") + } else { + val hf = currentHf[0] + val sf = currentHf[1] + val newForkInfo = ForkInfo(hf, sf) + if (newForkInfo > SnodeAPI.forkInfo) { + SnodeAPI.forkInfo = ForkInfo(hf,sf) + } else if (newForkInfo < SnodeAPI.forkInfo) { + Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}") + } + } + } + if (statusCode != 200) { + val exception = HTTPRequestFailedAtDestinationException( + statusCode, + body, + destination.description + ) + return deferred.reject(exception) + } + deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray())) + } + else -> { + if (statusCode != 200) { + val exception = HTTPRequestFailedAtDestinationException( + statusCode, + json, + destination.description + ) + return deferred.reject(exception) + } + deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray())) + } + } + } catch (exception: Exception) { + deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}.")) + } + } catch (exception: Exception) { + deferred.reject(exception) + } + } + } + + private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArray { + // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo + val infoLengthStringLength = infoLength.toString().length + if (size <= infoLength + infoLengthStringLength + 2/*l and e bytes*/) { + return byteArrayOf() + } + // Extract the response data as well + val dataSlice = slice(infoEndIndex + 1 until size - 1) + val dataSepIdx = dataSlice.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } + val responseBody = dataSlice.slice(dataSepIdx + 1 until dataSlice.size) + return responseBody.toByteArray() + } + // endregion } + +enum class Version(val value: String) { + V2("/loki/v2/lsrpc"), + V3("/loki/v3/lsrpc"), + V4("/oxen/v4/lsrpc"); +} + +data class OnionResponse( + val info: Map<*, *>, + val body: ByteArray? = null +) diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt index cd9ac4d1d8..3e31a52e5a 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt @@ -2,6 +2,7 @@ package org.session.libsession.snode import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred +import org.session.libsession.snode.OnionRequestAPI.Destination import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsignal.utilities.toHexString @@ -31,25 +32,29 @@ object OnionRequestEncryption { /** * Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. */ - internal fun encryptPayloadForDestination(payload: Map<*, *>, destination: OnionRequestAPI.Destination): Promise { + internal fun encryptPayloadForDestination( + payload: ByteArray, + destination: Destination, + version: Version + ): Promise { val deferred = deferred() ThreadUtils.queue { try { - // Wrapping isn't needed for file server or open group onion requests - when (destination) { - is OnionRequestAPI.Destination.Snode -> { - val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key - val payloadAsData = JsonUtil.toJson(payload).toByteArray() - val plaintext = encode(payloadAsData, mapOf( "headers" to "" )) - val result = AESGCM.encrypt(plaintext, snodeX25519PublicKey) - deferred.resolve(result) - } - is OnionRequestAPI.Destination.Server -> { - val plaintext = JsonUtil.toJson(payload).toByteArray() - val result = AESGCM.encrypt(plaintext, destination.x25519PublicKey) - deferred.resolve(result) + val plaintext = if (version == Version.V4) { + payload + } else { + // Wrapping isn't needed for file server or open group onion requests + when (destination) { + is Destination.Snode -> encode(payload, mapOf("headers" to "")) + is Destination.Server -> payload } } + val x25519PublicKey = when (destination) { + is Destination.Snode -> destination.snode.publicKeySet!!.x25519Key + is Destination.Server -> destination.x25519PublicKey + } + val result = AESGCM.encrypt(plaintext, x25519PublicKey) + deferred.resolve(result) } catch (exception: Exception) { deferred.reject(exception) } @@ -60,17 +65,16 @@ object OnionRequestEncryption { /** * Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. */ - internal fun encryptHop(lhs: OnionRequestAPI.Destination, rhs: OnionRequestAPI.Destination, previousEncryptionResult: EncryptionResult): Promise { + internal fun encryptHop(lhs: Destination, rhs: Destination, previousEncryptionResult: EncryptionResult): Promise { val deferred = deferred() ThreadUtils.queue { try { - val payload: MutableMap - when (rhs) { - is OnionRequestAPI.Destination.Snode -> { - payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key ) + val payload: MutableMap = when (rhs) { + is Destination.Snode -> { + mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key ) } - is OnionRequestAPI.Destination.Server -> { - payload = mutableMapOf( + is Destination.Server -> { + mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST", @@ -80,13 +84,12 @@ object OnionRequestEncryption { } } payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() - val x25519PublicKey: String - when (lhs) { - is OnionRequestAPI.Destination.Snode -> { - x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key + val x25519PublicKey = when (lhs) { + is Destination.Snode -> { + lhs.snode.publicKeySet!!.x25519Key } - is OnionRequestAPI.Destination.Server -> { - x25519PublicKey = lhs.x25519PublicKey + is Destination.Server -> { + lhs.x25519PublicKey } } val plaintext = encode(previousEncryptionResult.ciphertext, payload) 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 58b722f57c..9a17958952 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -26,6 +26,7 @@ import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils @@ -93,16 +94,26 @@ object SnodeAPI { } // Internal API - internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String? = null, parameters: Map): RawResponsePromise { + internal fun invoke( + method: Snode.Method, + snode: Snode, + parameters: Map, + publicKey: String? = null, + version: Version = Version.V3 + ): RawResponsePromise { val url = "${snode.address}:${snode.port}/storage_rpc/v1" + val deferred = deferred, Exception>() if (useOnionRequests) { - return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey) + OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { + val body = it.body ?: throw Error.Generic + deferred.resolve(JsonUtil.fromJson(body, Map::class.java)) + }.fail { deferred.reject(it) } } else { - val deferred = deferred, Exception>() ThreadUtils.queue { val payload = mapOf( "method" to method.rawValue, "params" to parameters ) try { - val json = HTTP.execute(HTTP.Verb.POST, url, payload) + val response = HTTP.execute(HTTP.Verb.POST, url, payload).toString() + val json = JsonUtil.fromJson(response, Map::class.java) deferred.resolve(json) } catch (exception: Exception) { val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException @@ -114,8 +125,8 @@ object SnodeAPI { deferred.reject(exception) } } - return deferred.promise } + return deferred.promise } internal fun getRandomSnode(): Promise { @@ -136,7 +147,12 @@ object SnodeAPI { deferred() ThreadUtils.queue { try { - val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true) + val response = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true) + val json = try { + JsonUtil.fromJson(response, Map::class.java) + } catch (exception: Exception) { + mapOf( "result" to response.toString()) + } val intermediate = json["result"] as? Map<*, *> val rawSnodes = intermediate?.get("service_node_states") as? List<*> if (rawSnodes != null) { @@ -211,7 +227,7 @@ object SnodeAPI { val promises = (1..validationCount).map { getRandomSnode().bind { snode -> retryIfNeeded(maxRetryCount) { - invoke(Snode.Method.OxenDaemonRPCCall, snode, null, parameters) + invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters) } } } @@ -275,14 +291,14 @@ object SnodeAPI { fun getSwarm(publicKey: String): Promise, Exception> { val cachedSwarm = database.getSwarm(publicKey) - if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { + return if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { val cachedSwarmCopy = mutableSetOf() // Workaround for a Kotlin compiler issue cachedSwarmCopy.addAll(cachedSwarm) - return task { cachedSwarmCopy } + task { cachedSwarmCopy } } else { val parameters = mapOf( "pubKey" to publicKey ) - return getRandomSnode().bind { - invoke(Snode.Method.GetSwarm, it, publicKey, parameters) + getRandomSnode().bind { + invoke(Snode.Method.GetSwarm, it, parameters, publicKey) }.map { parseSnodes(it).toSet() }.success { @@ -329,7 +345,7 @@ object SnodeAPI { } // Make the request - return invoke(Snode.Method.GetMessages, snode, publicKey, parameters) + return invoke(Snode.Method.GetMessages, snode, parameters, publicKey) } fun getMessages(publicKey: String): MessageListPromise { @@ -341,7 +357,7 @@ object SnodeAPI { } private fun getNetworkTime(snode: Snode): Promise, Exception> { - return invoke(Snode.Method.Info, snode, null, emptyMap()).map { rawResponse -> + return invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 snode to timestamp } @@ -375,7 +391,7 @@ object SnodeAPI { parameters["namespace"] = namespace } getSingleTargetSnode(destination).bind { snode -> - invoke(Snode.Method.SendMessage, snode, destination, parameters) + invoke(Snode.Method.SendMessage, snode, parameters, destination) } } } @@ -396,7 +412,7 @@ object SnodeAPI { "messages" to serverHashes, "signature" to Base64.encodeBytes(signature) ) - invoke(Snode.Method.DeleteMessage, snode, publicKey, deleteMessageParams).map { rawResponse -> + invoke(Snode.Method.DeleteMessage, snode, deleteMessageParams, publicKey).map { rawResponse -> val swarms = rawResponse["swarm"] as? Map ?: return@map mapOf() val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> val json = rawJSON as? Map ?: return@mapNotNull null @@ -466,7 +482,7 @@ object SnodeAPI { "timestamp" to timestamp, "signature" to Base64.encodeBytes(signature) ) - invoke(Snode.Method.DeleteAll, snode, userPublicKey, deleteMessageParams).map { + invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) }.fail { e -> Log.e("Loki", "Failed to clear data", e) diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/libsession/src/main/java/org/session/libsession/utilities/Address.kt index 3278907bd7..7b774602e1 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -25,8 +25,10 @@ class Address private constructor(address: String) : Parcelable, Comparable 2) { + return decodedGroupId.split("!", limit = 3)[2] + } + return decodedGroupId + } + fun isEncodedGroup(groupId: String): Boolean { return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(OPEN_GROUP_PREFIX) } @@ -54,6 +69,11 @@ object GroupUtil { return groupId.startsWith(OPEN_GROUP_PREFIX) } + @JvmStatic + fun isOpenGroupInbox(groupId: String): Boolean { + return groupId.startsWith(OPEN_GROUP_INBOX_PREFIX) + } + @JvmStatic fun isClosedGroup(groupId: String): Boolean { return groupId.startsWith(CLOSED_GROUP_PREFIX) diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt index 47223c8096..38d8838c0e 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt @@ -4,7 +4,7 @@ import android.content.Context import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import okio.Buffer -import org.session.libsession.messaging.file_server.FileServerAPIV2 +import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsignal.streams.ProfileCipherOutputStream import org.session.libsignal.utilities.ProfileAvatarData import org.session.libsignal.streams.DigestingRequestBody @@ -30,13 +30,13 @@ object ProfilePictureUtilities { var id: Long = 0 try { id = retryIfNeeded(4) { - FileServerAPIV2.upload(data) + FileServerApi.upload(data) }.get() } catch (e: Exception) { deferred.reject(e) } TextSecurePreferences.setLastProfilePictureUpload(context, Date().time) - val url = "${FileServerAPIV2.server}/files/$id" + val url = "${FileServerApi.server}/file/$id" TextSecurePreferences.setProfilePictureURL(context, url) deferred.resolve(Unit) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 37334f855f..d6b7938e83 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -40,6 +40,7 @@ import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.FutureTaskListener; import org.session.libsession.utilities.GroupRecord; +import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.ListenableFutureTask; import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.ProfilePictureModifiedEvent; @@ -314,6 +315,11 @@ public class Recipient implements RecipientModifiedListener { } else { return this.name; } + } else if (isOpenGroupInboxRecipient()){ + String inboxID = GroupUtil.getDecodedOpenGroupInbox(sessionID); + Contact contact = storage.getContactWithSessionID(inboxID); + if (contact == null) { return sessionID; } + return contact.displayName(Contact.ContactContext.REGULAR); } else { Contact contact = storage.getContactWithSessionID(sessionID); if (contact == null) { return sessionID; } @@ -431,6 +437,10 @@ public class Recipient implements RecipientModifiedListener { return address.isOpenGroup(); } + public boolean isOpenGroupInboxRecipient() { + return address.isOpenGroupInbox(); + } + public boolean isClosedGroupRecipient() { return address.isClosedGroup(); } diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 91e50a37d5..681fdfa12d 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -18,7 +18,7 @@ dependencies { implementation "androidx.annotation:annotation:1.2.0" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "org.whispersystems:curve25519-java:$curve25519Version" + implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt index 688dcbab74..aea1fce2d9 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -74,26 +74,26 @@ object HTTP { /** * Sync. Don't call from the main thread. */ - fun execute(verb: Verb, url: String, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): Map<*, *> { + fun execute(verb: Verb, url: String, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { return execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) } /** * Sync. Don't call from the main thread. */ - fun execute(verb: Verb, url: String, parameters: Map?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): Map<*, *> { - if (parameters != null) { + fun execute(verb: Verb, url: String, parameters: Map?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { + return if (parameters != null) { val body = JsonUtil.toJson(parameters).toByteArray() - return execute(verb = verb, url = url, body = body, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) + execute(verb = verb, url = url, body = body, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) } else { - return execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) + execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) } } /** * Sync. Don't call from the main thread. */ - fun execute(verb: Verb, url: String, body: ByteArray?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): Map<*, *> { + fun execute(verb: Verb, url: String, body: ByteArray?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { val request = Request.Builder().url(url) .removeHeader("User-Agent").addHeader("User-Agent", "WhatsApp") // Set a fake value .removeHeader("Accept-Language").addHeader("Accept-Language", "en-us") // Set a fake value @@ -109,14 +109,13 @@ object HTTP { } lateinit var response: Response try { - val connection: OkHttpClient - if (timeout != HTTP.timeout) { // Custom timeout + val connection = if (timeout != HTTP.timeout) { // Custom timeout if (useSeedNodeConnection) { throw IllegalStateException("Setting a custom timeout is only allowed for requests to snodes.") } - connection = getDefaultConnection(timeout) + getDefaultConnection(timeout) } else { - connection = if (useSeedNodeConnection) seedNodeConnection else defaultConnection + if (useSeedNodeConnection) seedNodeConnection else defaultConnection } response = connection.newCall(request.build()).execute() } catch (exception: Exception) { @@ -124,14 +123,9 @@ object HTTP { // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI throw HTTPRequestFailedException(0, null) } - when (val statusCode = response.code()) { + return when (val statusCode = response.code()) { 200 -> { - val bodyAsString = response.body()?.string() ?: throw Exception("An error occurred.") - try { - return JsonUtil.fromJson(bodyAsString, Map::class.java) - } catch (exception: Exception) { - return mapOf( "result" to bodyAsString) - } + response.body()?.bytes() ?: throw Exception("An error occurred.") } else -> { Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.") diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt new file mode 100644 index 0000000000..154b91ee20 --- /dev/null +++ b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt @@ -0,0 +1,15 @@ +package org.session.libsignal.utilities + +enum class IdPrefix(val value: String) { + STANDARD("05"), BLINDED("15"), UN_BLINDED("00"); + + companion object { + fun fromValue(rawValue: String): IdPrefix? = when(rawValue.take(2)) { + STANDARD.value -> STANDARD + BLINDED.value -> BLINDED + UN_BLINDED.value -> UN_BLINDED + else -> null + } + } + +} \ No newline at end of file diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java b/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java index 89af2cee3c..6625c91260 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java +++ b/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java @@ -1,6 +1,7 @@ package org.session.libsignal.utilities; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; @@ -30,6 +31,10 @@ public class JsonUtil { return fromJson(new String(serialized), clazz); } + public static T fromJson(String serialized, TypeReference typeReference) throws IOException { + return objectMapper.readValue(serialized, typeReference); + } + public static T fromJson(String serialized, Class clazz) throws IOException { return objectMapper.readValue(serialized, clazz); } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt index a5831800cc..e96cf160fc 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt @@ -1,12 +1,10 @@ package org.session.libsignal.utilities -import org.session.libsignal.utilities.Hex - -fun String.removing05PrefixIfNeeded(): String { - return if (length == 66) removePrefix("05") else this +fun String.removingIdPrefixIfNeeded(): String { + return if (length == 66 && IdPrefix.fromValue(this) != null) removeRange(0..1) else this } -fun ByteArray.removing05PrefixIfNeeded(): ByteArray { - val string = Hex.toStringCondensed(this).removing05PrefixIfNeeded() +fun ByteArray.removingIdPrefixIfNeeded(): ByteArray { + val string = Hex.toStringCondensed(this).removingIdPrefixIfNeeded() return Hex.fromStringCondensed(string) } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt index d9b616d302..a9d38956bb 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt @@ -10,9 +10,9 @@ object PublicKeyValidation { @JvmStatic fun isValid(candidate: String, expectedLength: Int, isPrefixRequired: Boolean): Boolean { val hexCharacters = "0123456789ABCDEF".toSet() - val isValidHexEncoding = hexCharacters.containsAll(candidate.toUpperCase().toSet()) + val isValidHexEncoding = hexCharacters.containsAll(candidate.uppercase().toSet()) val hasValidLength = candidate.length == expectedLength - val hasValidPrefix = if (isPrefixRequired) candidate.startsWith("05") else true + val hasValidPrefix = if (isPrefixRequired) IdPrefix.fromValue(candidate) != null else true return isValidHexEncoding && hasValidLength && hasValidPrefix } } diff --git a/settings.gradle b/settings.gradle index 2a51c756e9..3a42510472 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ rootProject.name = "session-android" include ':app' +include ':liblazysodium' include ':libsession' include ':libsignal' \ No newline at end of file