Add Session Id blinding (#862)

* feat: Add Session Id blinding

Including modified version of lazysodium-android to expose missing libsodium functions, we could build from a fork which we still need to setup.

* Add v4 onion request handling

* Update SOGS signature construction

* Fix SOGS signature construction

* Update onion request

* Update signature data

* Keep path prefixes for v4 endpoints

* Update SOGS signature message

* Rename to remove api version suffix

* Update onion response parsing

* Refactor file download paths

* Implement request batching

* Refactor batch response handling

* Handle batch endpoint responses

* Update batch endpoint responses

* Update attachment download handling

* Handle file downloads

* Handle inbox messages

* Fix issue with file downloads

* Preserve image bytearray encoding

* Refactor

* Open group message requests

* Check id blinding in user detail bottom sheet rather

* Message validation refactor

* Cache last inbox/outbox server ids

* Update message encryption/decryption

* Refactor

* Refactor

* Bypass user details bottom sheet in open groups for blinded session ids

* Fix capabilities call auth

* Refactor

* Revert default server details

* Update sodium dependency to forked repo

* Fix attachment upload

* Revert "Update sodium dependency to forked repo"

This reverts commit c7db9529f9.

* Add signed sodium lib

* Update contact id truncation and mention logic

* Open group inbox messaging fix

* Refactor

* Update blinded id check

* Fix open group message sends

* Fix crash on open group direct message send

* Direct message refactor

* Direct message encrypt/decrypt fixes

* Use updated curve25519 version

* Updated lazysodium dependency

* Update encryption/decryption calls

* Handle direct message parse errors

* Minor refactor

* Existing chat refactor

* Update encryption & decryption parameters

* Fix authenticated ciphertext size

* Set direct message sync target

* Update direct message thread lookup

* Add blinded id mapping table

* Add blinded id mapping table

* Update threads after sends

* Update open group message timestamp handling

* Filter unblinded contacts

* Format blinded id mentions

* Add message deleted field

* Hide open group inbox id

* Update message request response handling

* Update message request response sender handling

* Fix mentions of blinded ids

* Handle open group poll failure

* fix: add log for failed open group onion request, add decoding body for blinding required error at destination

* fix: change the error check

* Persist group members

* Reschedule polling after capabilities update

* Retry on other exceptions

* Minor refactor

* Open group profile fix

* Group member db schema update

* Fix ban request key

* Update ban response type

* Ban endpoint updates

* Ban endpoint updates

* Delete messages

Co-authored-by: charles <charles@oxen.io>
Co-authored-by: jubb <hjubb@users.noreply.github.com>
This commit is contained in:
ceokot 2022-08-10 18:17:48 +10:00 committed by GitHub
parent b1e954084c
commit bee287bb7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 3192 additions and 1190 deletions

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.avatars.ResourceContactPhoto import org.session.libsession.avatars.ResourceContactPhoto
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
@ -57,6 +58,11 @@ class ProfilePictureView @JvmOverloads constructor(
val apk = members.getOrNull(1)?.serialize() ?: "" val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk) additionalDisplayName = getUserDisplayName(apk)
} else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
} else { } else {
val publicKey = recipient.address.toString() val publicKey = recipient.address.toString()
this.publicKey = publicKey this.publicKey = publicKey

View File

@ -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.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment 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.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel 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
import org.session.libsession.utilities.Address.Companion.fromSerialized 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.MediaTypes
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsession.utilities.recipients.RecipientModifiedListener
import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey 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.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -167,6 +171,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
@ -177,9 +182,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val viewModel: ConversationViewModel by viewModels { private val viewModel: ConversationViewModel by viewModels {
var threadId = intent.getLongExtra(THREAD_ID, -1L) var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) { if (threadId == -1L) {
intent.getParcelableExtra<Address>(ADDRESS)?.let { address -> intent.getParcelableExtra<Address>(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) val recipient = Recipient.from(this, address, false)
threadId = threadDb.getOrCreateThreadIdFor(recipient) threadId = threadDb.getOrCreateThreadIdFor(recipient)
}
} ?: finish() } ?: finish()
} }
viewModelFactory.create(threadId) viewModelFactory.create(threadId)
@ -263,6 +284,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// Extras // Extras
const val THREAD_ID = "thread_id" const val THREAD_ID = "thread_id"
const val ADDRESS = "address" 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_ID = "scroll_message_id"
const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author"
// Request codes // Request codes
@ -508,7 +530,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun getLatestOpenGroupInfoIfNeeded() { private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return 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 // called from onCreate

View File

@ -7,7 +7,7 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import network.loki.messenger.databinding.ViewMentionCandidateBinding import network.loki.messenger.databinding.ViewMentionCandidateBinding
import org.session.libsession.messaging.mentions.Mention 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 import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView : LinearLayout { class MentionCandidateView : LinearLayout {
@ -34,7 +34,7 @@ class MentionCandidateView : LinearLayout {
profilePictureView.root.glide = glide!! profilePictureView.root.glide = glide!!
profilePictureView.root.update() profilePictureView.root.update()
if (openGroupServer != null && openGroupRoom != null) { 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 moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else { } else {
moderatorIconImageView.visibility = View.GONE moderatorIconImageView.visibility = View.GONE

View File

@ -7,7 +7,7 @@ import android.view.View
import android.widget.RelativeLayout import android.widget.RelativeLayout
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.session.libsession.messaging.mentions.Mention 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 import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView : RelativeLayout { class MentionCandidateView : RelativeLayout {
@ -34,7 +34,7 @@ class MentionCandidateView : RelativeLayout {
profilePictureView.root.glide = glide!! profilePictureView.root.glide = glide!!
profilePictureView.root.update() profilePictureView.root.update()
if (openGroupServer != null && openGroupRoom != null) { 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 moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else { } else {
moderatorIconImageView.visibility = View.GONE moderatorIconImageView.visibility = View.GONE

View File

@ -5,13 +5,17 @@ import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import network.loki.messenger.R 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.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager
class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long, class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long,
private val context: Context) : ActionMode.Callback { 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 openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! 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 { fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
@ -41,13 +48,13 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) { if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) {
if (openGroup == null) { return true } if (openGroup == null) { return true }
if (allSentByCurrentUser) { 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 } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser } if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser }
if (allSentByCurrentUser) { return true } if (allSentByCurrentUser) { return true }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey)
} }
fun userCanBanSelectedUsers(): Boolean { fun userCanBanSelectedUsers(): Boolean {
if (openGroup == null) { return false } 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 if (anySentByCurrentUser) { return false } // Users can't ban themselves
val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet() val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet()
if (selectedUsers.size > 1) { return false } if (selectedUsers.size > 1) { return false }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey)
} }
// Delete message // Delete message
menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems()

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.conversation.v2.messages package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
@ -23,16 +24,19 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext 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.libsession.utilities.ViewUtil
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -140,15 +144,27 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.root.glide = glide binding.profilePictureView.root.glide = glide
binding.profilePictureView.root.update(message.individualRecipient) binding.profilePictureView.root.update(message.individualRecipient)
binding.profilePictureView.root.setOnClickListener { 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) { if (thread.isOpenGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
val isModerator = OpenGroupAPIV2.isUserModerator( var standardPublicKey = ""
senderSessionID, var blindedPublicKey: String? = null
openGroup.room, if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) {
openGroup.server blindedPublicKey = senderSessionID
) } else {
standardPublicKey = senderSessionID
}
val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey)
binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator
} }
} }
@ -403,7 +419,7 @@ class VisibleMessageView : LinearLayout {
pressCallback = null pressCallback = null
} }
private fun showUserDetails(publicKey: String, threadID: Long) { private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
val userDetailsBottomSheet = UserDetailsBottomSheet() val userDetailsBottomSheet = UserDetailsBottomSheet()
val bundle = bundleOf( val bundle = bundleOf(
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,

View File

@ -11,6 +11,7 @@ import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2 import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
@ -20,39 +21,27 @@ object MentionUtilities {
@JvmStatic @JvmStatic
fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String {
val threadDB = DatabaseComponent.get(context).threadDatabase() return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
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()
} }
@JvmStatic @JvmStatic
fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { 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 @Suppress("NAME_SHADOWING") var text = text
val pattern = Pattern.compile("@[0-9a-fA-F]*") val pattern = Pattern.compile("@[0-9a-fA-F]*")
var matcher = pattern.matcher(text) var matcher = pattern.matcher(text)
val mentions = mutableListOf<Tuple2<Range<Int>, String>>() val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
var startIndex = 0 var startIndex = 0
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID)
if (matcher.find(startIndex)) { if (matcher.find(startIndex)) {
while (true) { while (true) {
val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ 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)) { val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false
TextSecurePreferences.getProfileName(context) val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) {
context.getString(R.string.MessageRecord_you)
} else { } else {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) 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) contact?.displayName(context)
} }
if (userDisplayName != null) { if (userDisplayName != null) {

View File

@ -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<BlindedIdMapping> {
val query = "$BLINDED_PK = ?"
val args = arrayOf(blindedId)
val mappings: MutableList<BlindedIdMapping> = 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<BlindedIdMapping> {
val query = "$SESSION_PK IS NOT NULL AND $SERVER_URL <> ?"
val args = arrayOf(server)
val mappings: MutableList<BlindedIdMapping> = mutableListOf()
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
mappings += readBlindedIdMapping(cursor)
}
}
return mappings
}
}

View File

@ -23,6 +23,8 @@ import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -92,4 +94,12 @@ public abstract class Database {
this.databaseHelper = databaseHelper; this.databaseHelper = databaseHelper;
} }
protected SQLiteDatabase getReadableDatabase() {
return databaseHelper.getReadableDatabase();
}
protected SQLiteDatabase getWritableDatabase() {
return databaseHelper.getWritableDatabase();
}
} }

View File

@ -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<GroupMemberRole> {
val query = "$GROUP_ID = ? AND $PROFILE_ID = ?"
val args = arrayOf(groupId, profileId)
val mappings: MutableList<GroupMember> = 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()
}
}
}

View File

@ -12,7 +12,7 @@ import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.PublicKeyValidation import org.session.libsignal.utilities.PublicKeyValidation
import org.session.libsignal.utilities.Snode 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.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper 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 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;" 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 // region Deprecated
private val deviceLinkCache = "loki_pairing_authorisation_cache" private val deviceLinkCache = "loki_pairing_authorisation_cache"
@ -423,14 +438,14 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
override fun getUserX25519KeyPair(): ECKeyPair { override fun getUserX25519KeyPair(): ECKeyPair {
val keyPair = IdentityKeyUtil.getIdentityKeyPair(context) 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) { fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val timestamp = Date().time.toString() val timestamp = Date().time.toString()
val index = "$groupPublicKey-$timestamp" 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 encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString()
val row = wrap(mapOf(closedGroupsEncryptionKeyPairIndex to index, Companion.encryptionKeyPairPublicKey to encryptionKeyPairPublicKey, val row = wrap(mapOf(closedGroupsEncryptionKeyPairIndex to index, Companion.encryptionKeyPairPublicKey to encryptionKeyPairPublicKey,
Companion.encryptionKeyPairPrivateKey to encryptionKeyPairPrivateKey )) Companion.encryptionKeyPairPrivateKey to encryptionKeyPairPrivateKey ))
@ -481,6 +496,53 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.delete(closedGroupPublicKeysTable, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) database.delete(closedGroupPublicKeysTable, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey))
} }
fun setServerCapabilities(serverName: String, serverCapabilities: List<String>) {
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<String> {
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 { override fun getForkInfo(): ForkInfo {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
val queryCursor = database.query(FORK_INFO_TABLE, arrayOf(HF_VALUE, SF_VALUE), "$DUMMY_KEY = $DUMMY_VALUE", null, null, null, null) val queryCursor = database.query(FORK_INFO_TABLE, arrayOf(HF_VALUE, SF_VALUE), "$DUMMY_KEY = $DUMMY_VALUE", null, null, null, null)

View File

@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor 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.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
@ -30,16 +30,16 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
} }
fun getAllV2OpenGroups(): Map<Long, OpenGroupV2> { fun getAllOpenGroups(): Map<Long, OpenGroup> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
var cursor: Cursor? = null var cursor: Cursor? = null
val result = mutableMapOf<Long, OpenGroupV2>() val result = mutableMapOf<Long, OpenGroup>()
try { try {
cursor = database.rawQuery("select * from $publicChatTable", null) cursor = database.rawQuery("select * from $publicChatTable", null)
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
val threadID = cursor.getLong(threadID) val threadID = cursor.getLong(threadID)
val string = cursor.getString(publicChat) val string = cursor.getString(publicChat)
val openGroup = OpenGroupV2.fromJSON(string) val openGroup = OpenGroup.fromJSON(string)
if (openGroup != null) result[threadID] = openGroup if (openGroup != null) result[threadID] = openGroup
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -50,25 +50,25 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
return result return result
} }
fun getOpenGroupChat(threadID: Long): OpenGroupV2? { fun getOpenGroupChat(threadID: Long): OpenGroup? {
if (threadID < 0) { if (threadID < 0) {
return null return null
} }
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor -> return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor ->
val json = cursor.getString(publicChat) 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) { if (threadID < 0) {
return return
} }
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues(2) val contentValues = ContentValues(2)
contentValues.put(Companion.threadID, threadID) 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())) database.insertOrUpdate(publicChatTable, contentValues, "${Companion.threadID} = ?", arrayOf(threadID.toString()))
} }

View File

@ -42,6 +42,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessage(long messageId);
public abstract void updateThreadId(long fromId, long toId);
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try { try {
addToDocument(messageId, MISMATCHED_IDENTITIES, addToDocument(messageId, MISMATCHED_IDENTITIES,

View File

@ -1031,6 +1031,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return threadDeleted 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) { fun deleteThread(threadId: Long) {
deleteThreads(setOf(threadId)) deleteThreads(setOf(threadId))
} }

View File

@ -93,12 +93,11 @@ public class MmsSmsDatabase extends Database {
MmsSmsDatabase.Reader reader = readerFor(cursor); MmsSmsDatabase.Reader reader = readerFor(cursor);
MessageRecord messageRecord; MessageRecord messageRecord;
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
while ((messageRecord = reader.getNext()) != null) { while ((messageRecord = reader.getNext()) != null) {
if ((Util.isOwnNumber(context, serializedAuthor) && messageRecord.isOutgoing()) || if ((isOwnNumber && messageRecord.isOutgoing()) ||
(!Util.isOwnNumber(context, serializedAuthor) (!isOwnNumber && messageRecord.getIndividualRecipient().getAddress().serialize().equals(serializedAuthor)))
&& messageRecord.getIndividualRecipient().getAddress().serialize().equals(serializedAuthor)
))
{ {
return messageRecord; return messageRecord;
} }

View File

@ -255,10 +255,6 @@ public class RecipientDatabase extends Database {
recipient.resolve().setApproved(approved); recipient.resolve().setApproved(approved);
} }
public void setAllApproved(List<String> addresses) {
}
public void setApprovedMe(@NonNull Recipient recipient, boolean approvedMe) { public void setApprovedMe(@NonNull Recipient recipient, boolean approvedMe) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(APPROVED_ME, approvedMe ? 1 : 0); values.put(APPROVED_ME, approvedMe ? 1 : 0);

View File

@ -575,6 +575,17 @@ public class SmsDatabase extends MessagingDatabase {
return threadDeleted; 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) { private boolean isDuplicate(IncomingTextMessage message, long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentUploadJob 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.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment 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.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel 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.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.Address 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.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
@ -73,7 +78,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
override fun setUserProfilePictureURL(newValue: String) { override fun setUserProfilePictureURL(newValue: String) {
val ourRecipient = Address.fromSerialized(getUserPublicKey()!!).let { val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
Recipient.from(context, it, false) Recipient.from(context, it, false)
} }
TextSecurePreferences.setProfilePictureURL(context, newValue) TextSecurePreferences.setProfilePictureURL(context, newValue)
@ -125,8 +130,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
runIncrement: Boolean, runIncrement: Boolean,
runThreadUpdate: Boolean): Long? { runThreadUpdate: Boolean): Long? {
var messageID: Long? = null var messageID: Long? = null
val senderAddress = Address.fromSerialized(message.sender!!) val senderAddress = fromSerialized(message.sender!!)
val isUserSender = (message.sender!! == getUserPublicKey()) 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<SignalServiceGroup> = when { val group: Optional<SignalServiceGroup> = when {
openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT))
groupPublicKey != null -> { groupPublicKey != null -> {
@ -138,17 +145,17 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val pointers = attachments.mapNotNull { val pointers = attachments.mapNotNull {
it.toSignalAttachment() it.toSignalAttachment()
} }
val targetAddress = if (isUserSender && !message.syncTarget.isNullOrEmpty()) { val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) {
Address.fromSerialized(message.syncTarget!!) fromSerialized(message.syncTarget!!)
} else if (group.isPresent) { } else if (group.isPresent) {
Address.fromSerialized(GroupUtil.getEncodedId(group.get())) fromSerialized(GroupUtil.getEncodedId(group.get()))
} else { } else {
senderAddress senderAddress
} }
val targetRecipient = Recipient.from(context, targetAddress, false) val targetRecipient = Recipient.from(context, targetAddress, false)
if (!targetRecipient.isGroupRecipient) { if (!targetRecipient.isGroupRecipient) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
if (isUserSender) { if (isUserSender || isUserBlindedSender) {
recipientDb.setApproved(targetRecipient, true) recipientDb.setApproved(targetRecipient, true)
} else { } else {
recipientDb.setApprovedMe(targetRecipient, true) recipientDb.setApprovedMe(targetRecipient, true)
@ -158,7 +165,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() 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()) val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull())
mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate)
} else { } else {
@ -176,7 +183,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val smsDatabase = DatabaseComponent.get(context).smsDatabase() val smsDatabase = DatabaseComponent.get(context).smsDatabase()
val isOpenGroupInvitation = (message.openGroupInvitation != null) 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) val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp)
else OutgoingTextMessage.from(message, targetRecipient) else OutgoingTextMessage.from(message, targetRecipient)
smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) 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) 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 } if (threadId.toInt() < 0) { return null }
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(LokiThreadDatabase.publicChatTable, "${LokiThreadDatabase.threadID} = ?", arrayOf( threadId.toString() )) { cursor -> return database.get(LokiThreadDatabase.publicChatTable, "${LokiThreadDatabase.threadID} = ?", arrayOf( threadId.toString() )) { cursor ->
val publicChatAsJson = cursor.getString(LokiThreadDatabase.publicChat) 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) 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 { override fun isDuplicateMessage(timestamp: Long): Boolean {
return getReceivedMessageTimestamps().contains(timestamp) return getReceivedMessageTimestamps().contains(timestamp)
} }
@ -335,7 +350,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author) val address = fromSerialized(author)
return database.getMessageFor(timestamp, address)?.getId() 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<String>, admins: Collection<String>, sentTimestamp: Long) { override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) {
val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) 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 updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase() 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<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
val userPublicKey = getUserPublicKey() 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 updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: ""
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf()) 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 { override fun isClosedGroup(publicKey: String): Boolean {
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey)
val address = Address.fromSerialized(publicKey) val address = fromSerialized(publicKey)
return address.isClosedGroup || isClosedGroup return address.isClosedGroup || isClosedGroup
} }
@ -528,8 +543,20 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration);
} }
override fun getAllV2OpenGroups(): Map<Long, OpenGroupV2> { override fun setServerCapabilities(server: String, capabilities: List<String>) {
return DatabaseComponent.get(context).lokiThreadDatabase().getAllV2OpenGroups() return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
}
override fun getServerCapabilities(server: String): List<String> {
return DatabaseComponent.get(context).lokiAPIDatabase().getServerCapabilities(server)
}
override fun getAllOpenGroups(): Map<Long, OpenGroup> {
return DatabaseComponent.get(context).lokiThreadDatabase().getAllOpenGroups()
}
override fun updateOpenGroup(openGroup: OpenGroup) {
OpenGroupManager.updateOpenGroup(openGroup, context)
} }
override fun getAllGroups(): List<GroupRecord> { override fun getAllGroups(): List<GroupRecord> {
@ -541,7 +568,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
override fun onOpenGroupAdded(urlAsString: String) { override fun onOpenGroupAdded(urlAsString: String) {
val server = OpenGroupV2.getServer(urlAsString) val server = OpenGroup.getServer(urlAsString)
OpenGroupManager.restartPollerForServer(server.toString().removeSuffix("/")) 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 { override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long {
val database = DatabaseComponent.get(context).threadDatabase() val database = DatabaseComponent.get(context).threadDatabase()
if (!openGroupID.isNullOrEmpty()) { return if (!openGroupID.isNullOrEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false)
return database.getThreadIdIfExistsFor(recipient) database.getThreadIdIfExistsFor(recipient)
} else if (!groupPublicKey.isNullOrEmpty()) { } else if (!groupPublicKey.isNullOrEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false)
return database.getOrCreateThreadIdFor(recipient) database.getOrCreateThreadIdFor(recipient)
} else { } else {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) val recipient = Recipient.from(context, fromSerialized(publicKey), false)
return database.getOrCreateThreadIdFor(recipient) database.getOrCreateThreadIdFor(recipient)
} }
} }
override fun getThreadId(publicKeyOrOpenGroupID: String): Long? { override fun getThreadId(publicKeyOrOpenGroupID: String): Long? {
val address = Address.fromSerialized(publicKeyOrOpenGroupID) val address = fromSerialized(publicKeyOrOpenGroupID)
return getThreadId(address) return getThreadId(address)
} }
@ -622,8 +649,13 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
override fun addContacts(contacts: List<ConfigurationMessage.Contact>) { override fun addContacts(contacts: List<ConfigurationMessage.Contact>) {
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
val threadDatabase = DatabaseComponent.get(context).threadDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase()
for (contact in contacts) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val address = Address.fromSerialized(contact.publicKey) 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) val recipient = Recipient.from(context, address, true)
if (!contact.profilePicture.isNullOrEmpty()) { if (!contact.profilePicture.isNullOrEmpty()) {
recipientDatabase.setProfileAvatar(recipient, contact.profilePicture) recipientDatabase.setProfileAvatar(recipient, contact.profilePicture)
@ -715,12 +747,48 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
threadDB.setHasSent(threadId, true) threadDB.setHasSent(threadId, true)
} else { } else {
val mmsDb = DatabaseComponent.get(context).mmsDatabase() val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val senderAddress = fromSerialized(senderPublicKey) val smsDb = DatabaseComponent.get(context).smsDatabase()
val requestSender = Recipient.from(context, senderAddress, false) val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
recipientDb.setApprovedMe(requestSender, true) val threadId = threadDB.getOrCreateThreadIdFor(sender)
threadDB.setHasSent(threadId, true)
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val mappings = mutableMapOf<String, BlindedIdMapping>()
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( val message = IncomingMediaMessage(
senderAddress, sender.address,
response.sentTimestamp!!, response.sentTimestamp!!,
-1, -1,
0, 0,
@ -735,7 +803,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.absent(), Optional.absent(),
Optional.absent() Optional.absent()
) )
val threadId = getOrCreateThreadIdFor(senderAddress)
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) 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) DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe)
} }
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
val database = DatabaseComponent.get(context).smsDatabase() val database = DatabaseComponent.get(context).smsDatabase()
val address = fromSerialized(senderPublicKey) val address = fromSerialized(senderPublicKey)
@ -764,4 +830,62 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return database.getLastSeenAndHasSent(threadId).second() ?: false 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
}
} }

View File

@ -43,6 +43,7 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; 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.Log;
import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.Pair;
import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.utilities.guava.Optional;
@ -447,6 +448,11 @@ public class ThreadDatabase extends Database {
return getConversationList(where); return getConversationList(where);
} }
public Cursor getBlindedConversationList() {
String where = TABLE_NAME + "." + ADDRESS + " LIKE '" + IdPrefix.BLINDED.getValue() + "%' ";
return getConversationList(where);
}
public Cursor getApprovedConversationList() { 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 + "%') " + 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 "; "AND " + ARCHIVED + " = 0 ";

View File

@ -14,8 +14,10 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase; 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 lokiV32 = 53;
private static final int lokiV33 = 54; private static final int lokiV33 = 54;
private static final int lokiV34 = 55; 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 // 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 static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -135,6 +138,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand()); db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
db.execSQL(LokiAPIDatabase.getCreateClosedGroupEncryptionKeyPairsTable()); db.execSQL(LokiAPIDatabase.getCreateClosedGroupEncryptionKeyPairsTable());
db.execSQL(LokiAPIDatabase.getCreateClosedGroupPublicKeysTable()); db.execSQL(LokiAPIDatabase.getCreateClosedGroupPublicKeysTable());
db.execSQL(LokiAPIDatabase.getCreateServerCapabilitiesCommand());
db.execSQL(LokiAPIDatabase.getCreateLastInboxMessageServerIdCommand());
db.execSQL(LokiAPIDatabase.getCreateLastOutboxMessageServerIdCommand());
db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand());
db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand());
db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand());
@ -161,6 +167,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.DROP_LEGACY_LAST_HASH); db.execSQL(LokiAPIDatabase.DROP_LEGACY_LAST_HASH);
db.execSQL(LokiAPIDatabase.INSERT_RECEIVED_HASHES_DATA); db.execSQL(LokiAPIDatabase.INSERT_RECEIVED_HASHES_DATA);
db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES); 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, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -369,6 +377,14 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES); 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(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -115,6 +115,8 @@ public class ThreadRecord extends DisplayRecord {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified)); return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
} else if (SmsDatabase.Types.isIdentityDefault(type)) { } else if (SmsDatabase.Types.isIdentityDefault(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified)); 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) { } else if (getCount() == 0) {
return new SpannableString(context.getString(R.string.ThreadRecord_empty_message)); return new SpannableString(context.getString(R.string.ThreadRecord_empty_message));
} else { } else {

View File

@ -42,4 +42,6 @@ interface DatabaseComponent {
fun sessionContactDatabase(): SessionContactDatabase fun sessionContactDatabase(): SessionContactDatabase
fun storage(): Storage fun storage(): Storage
fun attachmentProvider(): MessageDataProvider fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
fun groupMemberDatabase(): GroupMemberDatabase
} }

View File

@ -117,6 +117,14 @@ object DatabaseModule {
@Singleton @Singleton
fun provideSessionContactDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SessionContactDatabase(context,openHelper) 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 @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper)

View File

@ -4,19 +4,22 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart 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 import org.thoughtcrime.securesms.util.State
typealias DefaultGroups = List<OpenGroupAPIV2.DefaultGroup> typealias DefaultGroups = List<OpenGroupApi.DefaultGroup>
typealias GroupState = State<DefaultGroups> typealias GroupState = State<DefaultGroups>
class DefaultGroupsViewModel : ViewModel() { class DefaultGroupsViewModel : ViewModel() {
init { init {
OpenGroupAPIV2.getDefaultRoomsIfNeeded() OpenGroupApi.getDefaultServerCapabilities().map {
OpenGroupApi.getDefaultRoomsIfNeeded()
}
} }
val defaultRooms = OpenGroupAPIV2.defaultRooms.map<DefaultGroups, GroupState> { val defaultRooms = OpenGroupApi.defaultRooms.map<DefaultGroups, GroupState> {
State.Success(it) State.Success(it)
}.onStart { }.onStart {
emit(State.Loading) emit(State.Loading)

View File

@ -26,7 +26,7 @@ import network.loki.messenger.databinding.ActivityJoinPublicChatBinding
import network.loki.messenger.databinding.FragmentEnterChatUrlBinding import network.loki.messenger.databinding.FragmentEnterChatUrlBinding
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration 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.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient

View File

@ -4,16 +4,17 @@ import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2 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.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.concurrent.Executors import java.util.concurrent.Executors
object OpenGroupManager { object OpenGroupManager {
private val executorService = Executors.newScheduledThreadPool(4) private val executorService = Executors.newScheduledThreadPool(4)
private var pollers = mutableMapOf<String, OpenGroupPollerV2>() // One for each server private var pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server
private var isPolling = false private var isPolling = false
private val pollUpdaterLock = Any() private val pollUpdaterLock = Any()
@ -38,10 +39,10 @@ object OpenGroupManager {
if (isPolling) { return } if (isPolling) { return }
isPolling = true isPolling = true
val storage = MessagingModuleConfiguration.shared.storage 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 -> servers.forEach { server ->
pollers[server]?.stop() // Shouldn't be necessary pollers[server]?.stop() // Shouldn't be necessary
val poller = OpenGroupPollerV2(server, executorService) val poller = OpenGroupPoller(server, executorService)
poller.startIfNeeded() poller.startIfNeeded()
pollers[server] = poller pollers[server] = poller
} }
@ -67,17 +68,21 @@ object OpenGroupManager {
// Clear any existing data if needed // Clear any existing data if needed
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
storage.removeLastInboxMessageId(server)
storage.removeLastOutboxMessageId(server)
// Store the public key // Store the public key
storage.setOpenGroupPublicKey(server,publicKey) storage.setOpenGroupPublicKey(server,publicKey)
// Get group info // Get capabilities
OpenGroupAPIV2.getAuthToken(room, server).get() val capabilities = OpenGroupApi.getCapabilities(server).get()
// Get group info storage.setServerCapabilities(server, capabilities.capabilities)
val info = OpenGroupAPIV2.getInfo(room, server).get() // Get room info
val info = OpenGroupApi.getRoomInfo(room, server).get()
storage.setUserCount(room, server, info.activeUsers)
// Create the group locally if not available already // Create the group locally if not available already
if (threadID < 0) { if (threadID < 0) {
threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId 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) threadDB.setOpenGroupChat(openGroup, threadID)
} }
@ -86,7 +91,7 @@ object OpenGroupManager {
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
pollers[server]?.stop() pollers[server]?.stop()
pollers[server]?.startIfNeeded() ?: run { pollers[server]?.startIfNeeded() ?: run {
val poller = OpenGroupPollerV2(server, executorService) val poller = OpenGroupPoller(server, executorService)
pollers[server] = poller pollers[server] = poller
poller.startIfNeeded() poller.startIfNeeded()
} }
@ -102,7 +107,7 @@ object OpenGroupManager {
threadDB.setThreadArchived(threadID) threadDB.setThreadArchived(threadID)
val groupID = recipient.address.serialize() val groupID = recipient.address.serialize()
// Stop the poller if needed // 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) { if (openGroups.count() == 1) {
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
val poller = pollers[server] val poller = pollers[server]
@ -113,6 +118,8 @@ object OpenGroupManager {
// Delete // Delete
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
storage.removeLastInboxMessageId(server)
storage.removeLastOutboxMessageId(server)
val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase()
lokiThreadDB.removeOpenGroupChat(threadID) lokiThreadDB.removeOpenGroupChat(threadID)
ThreadUtils.queue { ThreadUtils.queue {
@ -123,9 +130,26 @@ object OpenGroupManager {
fun addOpenGroup(urlAsString: String, context: Context) { fun addOpenGroup(urlAsString: String, context: Context) {
val url = HttpUrl.parse(urlAsString) ?: return val url = HttpUrl.parse(urlAsString) ?: return
val server = OpenGroupV2.getServer(urlAsString) val server = OpenGroup.getServer(urlAsString)
val room = url.pathSegments().firstOrNull() ?: return val room = url.pathSegments().firstOrNull() ?: return
val publicKey = url.queryParameter("public_key") ?: return val publicKey = url.queryParameter("public_key") ?: return
add(server.toString().removeSuffix("/"), room, publicKey, context) 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
}
} }

View File

@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.groups
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import org.greenrobot.eventbus.EventBus 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.GroupUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent 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") throw IllegalStateException("Attempt to update open group info for non-existent DB record: $groupId")
} }
val info = OpenGroupAPIV2.getInfo(room, server).get() // store info again? val info = OpenGroupApi.getRoomInfo(room, server).get() // store info again?
OpenGroupAPIV2.getMemberCount(room, server).get()
EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room)) EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room))
} }

View File

@ -82,7 +82,7 @@ class ConversationView : LinearLayout {
} }
binding.muteIndicatorImageView.setImageResource(drawableRes) binding.muteIndicatorImageView.setImageResource(drawableRes)
val rawSnippet = thread.getDisplayBody(context) 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.text = snippet
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE

View File

@ -21,6 +21,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@ -83,8 +84,8 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() {
} }
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED
publicKeyTextView.text = publicKey publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener { publicKeyTextView.setOnLongClickListener {
val clipboard = val clipboard =
@ -103,6 +104,7 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() {
) )
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1) intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
startActivity(intent) startActivity(intent)
dismiss() dismiss()
} }

View File

@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.mms;
import android.content.Context; 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 { public class PushMediaConstraints extends MediaConstraints {
@ -21,26 +21,26 @@ public class PushMediaConstraints extends MediaConstraints {
@Override @Override
public int getImageMaxSize(Context context) { public int getImageMaxSize(Context context) {
return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier);
} }
@Override @Override
public int getGifMaxSize(Context context) { public int getGifMaxSize(Context context) {
return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier);
} }
@Override @Override
public int getVideoMaxSize(Context context) { public int getVideoMaxSize(Context context) {
return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier);
} }
@Override @Override
public int getAudioMaxSize(Context context) { public int getAudioMaxSize(Context context) {
return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier);
} }
@Override @Override
public int getDocumentMaxSize(Context context) { public int getDocumentMaxSize(Context context) {
return (int) (((double) FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier); return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier);
} }
} }

View File

@ -17,7 +17,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 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.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -72,13 +72,13 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
// Open Groups // Open Groups
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
val v2OpenGroups = threadDB.getAllV2OpenGroups() val openGroups = threadDB.getAllOpenGroups()
val v2OpenGroupServers = v2OpenGroups.map { it.value.server }.toSet() val openGroupServers = openGroups.map { it.value.server }.toSet()
for (server in v2OpenGroupServers) { for (server in openGroupServers) {
val poller = OpenGroupPollerV2(server, null) val poller = OpenGroupPoller(server, null)
poller.hasStarted = true poller.hasStarted = true
promises.add(poller.poll(true)) promises.add(poller.poll())
} }
// Wait until all the promises are resolved // Wait until all the promises are resolved

View File

@ -36,15 +36,22 @@ import android.service.notification.StatusBarNotification;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat; 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.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.Address;
import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.ServiceUtil;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.IdPrefix;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.Util; import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext; 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.ConversationActivityV2;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; 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.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase; 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.SessionMetaProtocol;
import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.SpanUtil;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ListIterator; import java.util.ListIterator;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -491,6 +502,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor); MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor);
ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase();
MessageRecord record; MessageRecord record;
Map<Long, String> cache = new HashMap<Long, String>();
while ((record = reader.getNext()) != null) { while ((record = reader.getNext()) != null) {
long id = record.getId(); long id = record.getId();
@ -534,16 +546,22 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (threadRecipients == null || !threadRecipients.isMuted()) { if (threadRecipients == null || !threadRecipients.isMuted()) {
if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { 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 // check if mentioned here
boolean isQuoteMentioned = false; boolean isQuoteMentioned = false;
if (record instanceof MmsMessageRecord) { if (record instanceof MmsMessageRecord) {
Quote quote = ((MmsMessageRecord) record).getQuote(); Quote quote = ((MmsMessageRecord) record).getQuote();
Address quoteAddress = quote != null ? quote.getAuthor() : null; Address quoteAddress = quote != null ? quote.getAuthor() : null;
String serializedAddress = quoteAddress != null ? quoteAddress.serialize() : 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)) if (body.toString().contains("@"+userPublicKey) || body.toString().contains("@"+blindedPublicKey) || isQuoteMentioned) {
|| isQuoteMentioned) {
notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck));
} }
} else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) {
@ -558,6 +576,19 @@ public class DefaultMessageNotifier implements MessageNotifier {
return notificationState; 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) { private void updateBadge(Context context, int count) {
try { try {
if (count == 0) ShortcutBadger.removeCount(context); if (count == 0) ShortcutBadger.removeCount(context);

View File

@ -1,12 +1,14 @@
package org.thoughtcrime.securesms.notifications package org.thoughtcrime.securesms.notifications
import android.content.Context import android.content.Context
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.functional.map
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.Version
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -43,7 +45,7 @@ object LokiPushNotificationManager {
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int val code = json["code"] as? Int
if (code != null && code != 0) { if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, false) TextSecurePreferences.setIsUsingFCM(context, false)
@ -72,7 +74,7 @@ object LokiPushNotificationManager {
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int val code = json["code"] as? Int
if (code != null && code != 0) { if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, true) TextSecurePreferences.setIsUsingFCM(context, true)
@ -100,7 +102,7 @@ object LokiPushNotificationManager {
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, pnServerPublicKey, "/loki/v2/lsrpc").map { json -> getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int val code = json["code"] as? Int
if (code == null || code == 0) { 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: ${json["message"] as? String ?: "null"}.")
@ -110,4 +112,10 @@ object LokiPushNotificationManager {
} }
} }
} }
private fun getResponseBody(request: Request): Promise<Map<*, *>, Exception> {
return OnionRequestAPI.sendOnionRequest(request, server, pnServerPublicKey, Version.V2).map { response ->
JsonUtil.fromJson(response.body, Map::class.java)
}
}
} }

View File

@ -7,7 +7,7 @@ import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.MessageSender
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
@ -86,7 +86,7 @@ class DefaultConversationRepository @Inject constructor(
override fun isOxenHostedOpenGroup(threadId: Long): Boolean { override fun isOxenHostedOpenGroup(threadId: Long): Boolean {
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) val openGroup = lokiThreadDb.getOpenGroupChat(threadId)
return openGroup?.publicKey == OpenGroupAPIV2.defaultServerPublicKey return openGroup?.publicKey == OpenGroupApi.defaultServerPublicKey
} }
override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? {
@ -153,7 +153,7 @@ class DefaultConversationRepository @Inject constructor(
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) val openGroup = lokiThreadDb.getOpenGroupChat(threadId)
if (openGroup != null) { if (openGroup != null) {
lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID ->
OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success { .success {
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
@ -205,7 +205,7 @@ class DefaultConversationRepository @Inject constructor(
messageServerIDs[messageServerID] = message messageServerIDs[messageServerID] = message
} }
for ((messageServerID, message) in messageServerIDs) { for ((messageServerID, message) in messageServerIDs) {
OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success { .success {
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)
}.fail { error -> }.fail { error ->
@ -228,7 +228,7 @@ class DefaultConversationRepository @Inject constructor(
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!
OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server) OpenGroupApi.ban(sessionID, openGroup.room, openGroup.server)
.success { .success {
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
}.fail { error -> }.fail { error ->
@ -240,7 +240,7 @@ class DefaultConversationRepository @Inject constructor(
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!
OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server)
.success { .success {
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
}.fail { error -> }.fail { error ->

View File

@ -12,6 +12,7 @@ import android.graphics.drawable.BitmapDrawable
import android.text.TextPaint import android.text.TextPaint
import android.text.TextUtils import android.text.TextUtils
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.IdPrefix
import java.math.BigInteger import java.math.BigInteger
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Locale import java.util.Locale
@ -66,7 +67,7 @@ object AvatarPlaceholderGenerator {
fun extractLabel(content: String): String { fun extractLabel(content: String): String {
val trimmedContent = content.trim() val trimmedContent = content.trim()
if (trimmedContent.isEmpty()) return EMPTY_LABEL 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() trimmedContent[2].toString()
} else { } else {
val splitWords = trimmedContent.split(Regex("\\W")) val splitWords = trimmedContent.split(Regex("\\W"))

View File

@ -20,4 +20,5 @@ object ContactUtilities {
} }
return result return result
} }
} }

View File

@ -9,7 +9,7 @@ lifecycleVersion=2.3.1
daggerVersion=2.40.1 daggerVersion=2.40.1
glideVersion=4.11.0 glideVersion=4.11.0
kovenantVersion=3.3.0 kovenantVersion=3.3.0
curve25519Version=0.5.0 curve25519Version=0.6.0
protobufVersion=2.5.0 protobufVersion=2.5.0
okhttpVersion=3.12.1 okhttpVersion=3.12.1
jacksonDatabindVersion=2.9.8 jacksonDatabindVersion=2.9.8

View File

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

Binary file not shown.

View File

@ -18,7 +18,8 @@ android {
dependencies { dependencies {
implementation project(":libsignal") 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 "net.java.dev.jna:jna:5.8.0@aar"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
@ -36,7 +37,7 @@ dependencies {
implementation 'com.esotericsoftware:kryo:5.1.1' implementation 'com.esotericsoftware:kryo:5.1.1'
implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" 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 "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"

View File

@ -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.control.MessageRequestResponse
import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment 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.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel 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.Address
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -54,13 +56,20 @@ interface StorageProtocol {
fun setAuthToken(room: String, server: String, newValue: String) fun setAuthToken(room: String, server: String, newValue: String)
fun removeAuthToken(room: String, server: String) fun removeAuthToken(room: String, server: String)
// Servers
fun setServerCapabilities(server: String, capabilities: List<String>)
fun getServerCapabilities(server: String): List<String>
// Open Groups // Open Groups
fun getAllV2OpenGroups(): Map<Long, OpenGroupV2> fun getAllOpenGroups(): Map<Long, OpenGroup>
fun getV2OpenGroup(threadId: Long): OpenGroupV2? fun updateOpenGroup(openGroup: OpenGroup)
fun getOpenGroup(threadId: Long): OpenGroup?
fun addOpenGroup(urlAsString: String) fun addOpenGroup(urlAsString: String)
fun onOpenGroupAdded(urlAsString: String) fun onOpenGroupAdded(urlAsString: String)
fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: 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 // Open Group Public Keys
fun getOpenGroupPublicKey(server: String): String? fun getOpenGroupPublicKey(server: String): String?
@ -167,4 +176,16 @@ interface StorageProtocol {
fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean)
fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long)
fun conversationHasOutgoing(userPublicKey: String): Boolean 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
} }

View File

@ -0,0 +1,8 @@
package org.session.libsession.messaging
data class BlindedIdMapping(
val blindedId: String,
val sessionId: String?,
val serverUrl: String,
val serverId: String
)

View File

@ -44,7 +44,7 @@ class Contact(val sessionID: String) {
// In open groups, where it's more likely that multiple users have the same name, // 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. // we display a bit of the Session ID after a user's display name for added context.
name?.let { name?.let {
return "$name (...${sessionID.takeLast(8)})" return "$name (${sessionID.take(4)}...${sessionID.takeLast(4)})"
} }
return null return null
} }

View File

@ -6,14 +6,13 @@ import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.RequestBody import okhttp3.RequestBody
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
object FileServerAPIV2 { object FileServerApi {
private const val serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" private const val serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
const val server = "http://filev2.getsession.org" const val server = "http://filev2.getsession.org"
@ -52,8 +51,8 @@ object FileServerAPIV2 {
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON) return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
} }
private fun send(request: Request): Promise<Map<*, *>, Exception> { private fun send(request: Request): Promise<ByteArray, Exception> {
val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupAPIV2.Error.InvalidURL) val url = HttpUrl.parse(server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = HttpUrl.Builder() val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme()) .scheme(url.scheme())
.host(url.host()) .host(url.host())
@ -73,29 +72,37 @@ object FileServerAPIV2 {
HTTP.Verb.POST -> requestBuilder.post(createBody(request.parameters)!!) HTTP.Verb.POST -> requestBuilder.post(createBody(request.parameters)!!)
HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.parameters)) HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.parameters))
} }
if (request.useOnionRouting) { return if (request.useOnionRouting) {
return OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).fail { e -> OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map {
it.body ?: throw Error.ParsingFailed
}.fail { e ->
Log.e("Loki", "File server request failed.", e) Log.e("Loki", "File server request failed.", e)
} }
} else { } 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<Long, Exception> { fun upload(file: ByteArray): Promise<Long, Exception> {
val base64EncodedFile = Base64.encodeBytes(file) val base64EncodedFile = Base64.encodeBytes(file)
val parameters = mapOf( "file" to base64EncodedFile ) val parameters = mapOf( "file" to base64EncodedFile )
val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters) val request = Request(
return send(request).map { json -> verb = HTTP.Verb.POST,
json["result"] as? Long ?: throw OpenGroupAPIV2.Error.ParsingFailed 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<ByteArray, Exception> { fun download(file: String): Promise<ByteArray, Exception> {
val request = Request(verb = HTTP.Verb.GET, endpoint = "files/$file") val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file")
return send(request).map { json -> return send(request)
val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed
Base64.decode(base64EncodedFile) ?: throw Error.ParsingFailed
}
} }
} }

View File

@ -2,7 +2,7 @@ package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration 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.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment 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) messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachment.attachmentId, this.databaseMessageID)
tempFile = createTempFile() tempFile = createTempFile()
val openGroupV2 = storage.getV2OpenGroup(threadID) val openGroup = storage.getOpenGroup(threadID)
if (openGroupV2 == null) { if (openGroup == null) {
Log.d("AttachmentDownloadJob", "downloading normal attachment") Log.d("AttachmentDownloadJob", "downloading normal attachment")
DownloadUtilities.downloadFile(tempFile, attachment.url) DownloadUtilities.downloadFile(tempFile, attachment.url)
} else { } else {
Log.d("AttachmentDownloadJob", "downloading open group attachment") Log.d("AttachmentDownloadJob", "downloading open group attachment")
val url = HttpUrl.parse(attachment.url)!! val url = HttpUrl.parse(attachment.url)!!
val fileID = url.pathSegments().last() 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) tempFile.writeBytes(it)
} }
} }

View File

@ -6,9 +6,10 @@ import com.esotericsoftware.kryo.io.Output
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import okio.Buffer import okio.Buffer
import org.session.libsession.messaging.MessagingModuleConfiguration 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.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.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.DecodedAudio 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 messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID) val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID)
?: return handleFailure(Error.NoAttachment) ?: return handleFailure(Error.NoAttachment)
val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong()) val openGroup = storage.getOpenGroup(threadID.toLong())
if (v2OpenGroup != null) { if (openGroup != null) {
val keyAndResult = upload(attachment, v2OpenGroup.server, false) { val keyAndResult = upload(attachment, openGroup.server, false) {
OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server) OpenGroupApi.upload(it, openGroup.room, openGroup.server)
} }
handleSuccess(attachment, keyAndResult.first, keyAndResult.second) handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
} else { } else {
val keyAndResult = upload(attachment, FileServerAPIV2.server, true) { val keyAndResult = upload(attachment, FileServerApi.server, true) {
FileServerAPIV2.upload(it) FileServerApi.upload(it)
} }
handleSuccess(attachment, keyAndResult.first, keyAndResult.second) 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 id = upload(data).get()
val digest = drb.transmittedDigest val digest = drb.transmittedDigest
// Return // 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) { 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) 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) { private fun handlePermanentFailure(e: Exception) {

View File

@ -2,8 +2,8 @@ package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -23,7 +23,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
val openGroupId: String? get() { val openGroupId: String? get() {
val url = HttpUrl.parse(joinUrl) ?: return null 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 val room = url.pathSegments().firstOrNull() ?: return null
return "$server.$room" return "$server.$room"
} }
@ -31,25 +31,29 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
override fun execute() { override fun execute() {
try { try {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL } val allOpenGroups = storage.getAllOpenGroups().map { it.value.joinURL }
if (allV2OpenGroups.contains(joinUrl)) { if (allOpenGroups.contains(joinUrl)) {
Log.e("OpenGroupDispatcher", "Failed to add group because",DuplicateGroupException()) Log.e("OpenGroupDispatcher", "Failed to add group because", DuplicateGroupException())
delegate?.handleJobFailed(this, DuplicateGroupException()) delegate?.handleJobFailed(this, DuplicateGroupException())
return return
} }
// get image // get image
val url = HttpUrl.parse(joinUrl) ?: throw Exception("Group joinUrl isn't valid") 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 serverString = server.toString().removeSuffix("/")
val publicKey = url.queryParameter("public_key") ?: throw Exception("Group public key isn't valid") 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") val room = url.pathSegments().firstOrNull() ?: throw Exception("Group room isn't valid")
storage.setOpenGroupPublicKey(serverString,publicKey) storage.setOpenGroupPublicKey(serverString, publicKey)
val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(url.pathSegments().firstOrNull()!!, serverString).get()
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
// get info and auth token // get info and auth token
storage.addOpenGroup(joinUrl) storage.addOpenGroup(joinUrl)
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.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) storage.updateTimestampUpdated(groupId, System.currentTimeMillis())
}
storage.onOpenGroupAdded(joinUrl) storage.onOpenGroupAdded(joinUrl)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("OpenGroupDispatcher", "Failed to add group because",e) Log.e("OpenGroupDispatcher", "Failed to add group because",e)

View File

@ -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.handle
import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.sending_receiving.handleVisibleMessage
import org.session.libsession.messaging.utilities.Data 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.libsession.utilities.SSKEnvironment
import org.session.libsignal.protos.UtilProtos import org.session.libsignal.protos.UtilProtos
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
data class MessageReceiveParameters( data class MessageReceiveParameters(
@ -72,12 +75,13 @@ class BatchMessageReceiveJob(
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context val context = MessagingModuleConfiguration.shared.context
val localUserPublicKey = storage.getUserPublicKey() val localUserPublicKey = storage.getUserPublicKey()
val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) }
// parse and collect IDs // parse and collect IDs
messages.forEach { messageParameters -> messages.forEach { messageParameters ->
val (data, serverHash, openGroupMessageServerID) = messageParameters val (data, serverHash, openGroupMessageServerID) = messageParameters
try { try {
val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID) val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey)
message.serverHash = serverHash message.serverHash = serverHash
val threadID = getThreadId(message, storage) val threadID = getThreadId(message, storage)
val parsedParams = ParsedMessage(messageParameters, message, proto) val parsedParams = ParsedMessage(messageParameters, message, proto)
@ -111,7 +115,9 @@ class BatchMessageReceiveJob(
runProfileUpdate = true runProfileUpdate = true
) )
if (messageId != null) { 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 { } else {
MessageReceiver.handle(message, proto, openGroupID) MessageReceiver.handle(message, proto, openGroupID)

View File

@ -1,7 +1,7 @@
package org.session.libsession.messaging.jobs package org.session.libsession.messaging.jobs
import org.session.libsession.messaging.MessagingModuleConfiguration 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.messaging.utilities.Data
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
@ -15,8 +15,9 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
override fun execute() { override fun execute() {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
try { try {
val info = OpenGroupAPIV2.getInfo(room, server).get() val info = OpenGroupApi.getRoomInfo(room, server).get()
val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(info.id, server).get() val imageId = info.imageId ?: return
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, info.token, imageId).get()
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.updateProfilePicture(groupId, bytes) storage.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) storage.updateTimestampUpdated(groupId, System.currentTimeMillis())

View File

@ -2,6 +2,7 @@ package org.session.libsession.messaging.jobs
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred 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.MessageReceiver
import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.sending_receiving.handle
import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.Data
@ -32,7 +33,10 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val
val deferred = deferred<Unit, Exception>() val deferred = deferred<Unit, Exception>()
try { try {
val isRetry: Boolean = failureCount != 0 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 message.serverHash = serverHash
MessageReceiver.handle(message, proto, this.openGroupID) MessageReceiver.handle(message, proto, this.openGroupID)
this.handleSuccess() this.handleSuccess()

View File

@ -3,8 +3,6 @@ package org.session.libsession.messaging.jobs
import com.esotericsoftware.kryo.Kryo import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.io.Input import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output 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.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination

View File

@ -13,6 +13,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.PushNoti
import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.Data
import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.Version
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.JsonUtil 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 body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(4) { retryIfNeeded(4) {
OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, "/loki/v2/lsrpc").map { json -> OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, Version.V2).map { response ->
val code = json["code"] as? Int val code = response.info["code"] as? Int
if (code == null || code == 0) { 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 -> }.fail { exception ->
Log.d("Loki", "Couldn't notify PN server due to error: $exception.") Log.d("Loki", "Couldn't notify PN server due to error: $exception.")

View File

@ -23,11 +23,13 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th
val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val numberToDelete = messageServerIds.size val numberToDelete = messageServerIds.size
Log.d(TAG, "Deleting $numberToDelete messages") Log.d(TAG, "Deleting $numberToDelete messages")
var numberDeleted = 0
messageServerIds.forEach { serverId -> messageServerIds.forEach { serverId ->
val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach
dataProvider.deleteMessage(messageId, isSms) dataProvider.deleteMessage(messageId, isSms)
numberDeleted++
} }
Log.d(TAG, "Deleted $numberToDelete messages successfully") Log.d(TAG, "Deleted $numberDeleted messages successfully")
delegate?.handleJobSucceeded(this) delegate?.handleJobSucceeded(this)
} }

View File

@ -1,7 +1,6 @@
package org.session.libsession.messaging.messages package org.session.libsession.messaging.messages
import org.session.libsession.messaging.MessagingModuleConfiguration 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.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
@ -14,13 +13,27 @@ sealed class Destination {
class ClosedGroup(var groupPublicKey: String) : Destination() { class ClosedGroup(var groupPublicKey: String) : Destination() {
internal constructor(): this("") internal constructor(): this("")
} }
class OpenGroupV2(var room: String, var server: String) : Destination() { class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() {
internal constructor(): this("", "") internal constructor(): this("", "")
} }
class OpenGroup(
var roomToken: String = "",
var server: String = "",
var whisperTo: List<String> = emptyList(),
var whisperMods: Boolean = false,
var fileIds: List<String> = emptyList()
) : Destination()
class OpenGroupInbox(
var server: String,
var serverPublicKey: String,
var blindedPublicKey: String
) : Destination()
companion object { companion object {
fun from(address: Address): Destination { fun from(address: Address, fileIds: List<String> = emptyList()): Destination {
return when { return when {
address.isContact -> { address.isContact -> {
Contact(address.contactIdentifier()) Contact(address.contactIdentifier())
@ -33,11 +46,17 @@ sealed class Destination {
address.isOpenGroup -> { address.isOpenGroup -> {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadID = storage.getThreadId(address)!! val threadID = storage.getThreadId(address)!!
when (val openGroup = storage.getV2OpenGroup(threadID)) { storage.getOpenGroup(threadID)?.let {
is org.session.libsession.messaging.open_groups.OpenGroupV2 OpenGroup(roomToken = it.room, server = it.server, fileIds = fileIds)
-> Destination.OpenGroupV2(openGroup.room, openGroup.server) } ?: throw Exception("Missing open group for thread with ID: $threadID.")
else -> 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 -> { else -> {
throw Exception("TODO: Handle legacy closed groups.") throw Exception("TODO: Handle legacy closed groups.")

View File

@ -1,16 +1,12 @@
package org.session.libsession.messaging.messages.control package org.session.libsession.messaging.messages.control
import com.google.protobuf.ByteString 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.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.DataMessage 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.toHexString
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -140,7 +136,7 @@ class ClosedGroupControlMessage() : ControlMessage() {
closedGroupControlMessage.publicKey = kind.publicKey closedGroupControlMessage.publicKey = kind.publicKey
closedGroupControlMessage.name = kind.name closedGroupControlMessage.name = kind.name
val encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder() 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()) encryptionKeyPair.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize())
closedGroupControlMessage.encryptionKeyPair = encryptionKeyPair.build() closedGroupControlMessage.encryptionKeyPair = encryptionKeyPair.build()
closedGroupControlMessage.addAllMembers(kind.members) closedGroupControlMessage.addAllMembers(kind.members)

View File

@ -11,7 +11,7 @@ import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.protos.SignalServiceProtos 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.toHexString
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
@ -36,7 +36,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
val publicKey = proto.publicKey.toByteArray().toHexString() val publicKey = proto.publicKey.toByteArray().toHexString()
val name = proto.name val name = proto.name
val encryptionKeyPairAsProto = proto.encryptionKeyPair val encryptionKeyPairAsProto = proto.encryptionKeyPair
val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray().removing05PrefixIfNeeded()), val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray().removingIdPrefixIfNeeded()),
DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray()))
val members = proto.membersList.map { it.toByteArray().toHexString() } val members = proto.membersList.map { it.toByteArray().toHexString() }
val admins = proto.adminsList.map { it.toByteArray().toHexString() } val admins = proto.adminsList.map { it.toByteArray().toHexString() }
@ -50,7 +50,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey)) result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey))
result.name = name result.name = name
val encryptionKeyPairAsProto = SignalServiceProtos.KeyPair.newBuilder() 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()) encryptionKeyPairAsProto.privateKey = ByteString.copyFrom(encryptionKeyPair!!.privateKey.serialize())
result.encryptionKeyPair = encryptionKeyPairAsProto.build() result.encryptionKeyPair = encryptionKeyPairAsProto.build()
result.addAllMembers(members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) result.addAllMembers(members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) })
@ -134,8 +134,8 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
} }
if (group.isOpenGroup) { if (group.isOpenGroup) {
val threadID = storage.getThreadId(group.encodedId) ?: continue val threadID = storage.getThreadId(group.encodedId) ?: continue
val openGroupV2 = storage.getV2OpenGroup(threadID) val openGroup = storage.getOpenGroup(threadID)
val shareUrl = openGroupV2?.joinURL ?: continue val shareUrl = openGroup?.joinURL ?: continue
openGroups.add(shareUrl) openGroups.add(shareUrl)
} }
} }

View File

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

View File

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

View File

@ -5,25 +5,27 @@ import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import java.util.Locale import java.util.Locale
data class OpenGroupV2( data class OpenGroup(
val server: String, val server: String,
val room: String, val room: String,
val id: String, val id: String,
val name: 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, server = server,
room = room, room = room,
id = "$server.$room", id = "$server.$room",
name = name, name = name,
publicKey = publicKey, publicKey = publicKey,
infoUpdates = infoUpdates,
) )
companion object { companion object {
fun fromJSON(jsonAsString: String): OpenGroupV2? { fun fromJSON(jsonAsString: String): OpenGroup? {
return try { return try {
val json = JsonUtil.fromJson(jsonAsString) val json = JsonUtil.fromJson(jsonAsString)
if (!json.has("room")) return null if (!json.has("room")) return null
@ -31,7 +33,9 @@ data class OpenGroupV2(
val server = json.get("server").asText().toLowerCase(Locale.US) val server = json.get("server").asText().toLowerCase(Locale.US)
val displayName = json.get("displayName").asText() val displayName = json.get("displayName").asText()
val publicKey = json.get("publicKey").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) { } catch (e: Exception) {
Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e);
null null
@ -54,7 +58,10 @@ data class OpenGroupV2(
"server" to server, "server" to server,
"displayName" to name, "displayName" to name,
"publicKey" to publicKey, "publicKey" to publicKey,
"infoUpdates" to infoUpdates.toString(),
) )
val joinURL: String get() = "$server/$room?public_key=$publicKey" val joinURL: String get() = "$server/$room?public_key=$publicKey"
val groupId: String get() = "$server.$room"
} }

View File

@ -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<String, Set<String>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs)
private val curve = Curve25519.getInstance(Curve25519.BEST)
val defaultRooms = MutableSharedFlow<List<DefaultGroup>>(replay = 1)
private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>()
private var hasUpdatedLastOpenDate = false
private val 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<OpenGroupMessageV2>, val deletions: List<MessageDeletion>, val moderators: List<String>)
data class MessageDeletion(
@JsonProperty("id")
val id: Long = 0,
@JsonProperty("deleted_message_id")
val deletedMessageServerID: Long = 0
) {
companion object {
val empty = MessageDeletion()
}
}
data class Request(
val verb: HTTP.Verb,
val room: String?,
val server: String,
val endpoint: String,
val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
/**
* Always `true` under normal circumstances. You might want to disable
* this when running over Lokinet.
*/
val useOnionRouting: Boolean = true
)
private fun createBody(parameters: Any?): RequestBody? {
if (parameters == null) return null
val parametersAsJSON = JsonUtil.toJson(parameters)
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
}
private fun send(request: Request): Promise<Map<*, *>, Exception> {
val url = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
.host(url.host())
.port(url.port())
.addPathSegments(request.endpoint)
if (request.verb == GET) {
for ((key, value) in request.queryParameters) {
urlBuilder.addQueryParameter(key, value)
}
}
fun execute(token: String?): Promise<Map<*, *>, Exception> {
val 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<ByteArray, Exception> {
val request = Request(verb = GET, room = roomID, server = server, endpoint = "rooms/$roomID/image", isAuthRequired = false)
return send(request).map { json ->
val result = json["result"] as? String ?: throw Error.ParsingFailed
decode(result)
}
}
// region Authorization
fun getAuthToken(room: String, server: String): Promise<String, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
return storage.getAuthToken(room, server)?.let {
Promise.of(it)
} ?: run {
requestNewAuthToken(room, server)
.bind { claimAuthToken(it, room, server) }
.success { authToken ->
storage.setAuthToken(room, server, authToken)
}
.fail { exception ->
Log.e("Loki", "Failed to get auth token", exception)
}
}
}
fun requestNewAuthToken(room: String, server: String): Promise<String, Exception> {
val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() }
?: return Promise.ofFail(Error.Generic)
val queryParameters = mutableMapOf( "public_key" to publicKey.toHexString() )
val request = Request(GET, room, server, "auth_token_challenge", queryParameters, isAuthRequired = false, parameters = null)
return send(request).map { json ->
val challenge = json["challenge"] as? Map<*, *> ?: throw Error.ParsingFailed
val base64EncodedCiphertext = challenge["ciphertext"] as? String ?: throw Error.ParsingFailed
val base64EncodedEphemeralPublicKey = challenge["ephemeral_public_key"] as? String ?: throw Error.ParsingFailed
val ciphertext = decode(base64EncodedCiphertext)
val ephemeralPublicKey = decode(base64EncodedEphemeralPublicKey)
val symmetricKey = AESGCM.generateSymmetricKey(ephemeralPublicKey, privateKey)
val tokenAsData = try {
AESGCM.decrypt(ciphertext, symmetricKey)
} catch (e: Exception) {
throw Error.DecryptionFailed
}
tokenAsData.toHexString()
}
}
fun claimAuthToken(authToken: String, room: String, server: String): Promise<String, Exception> {
val parameters = mapOf( "public_key" to MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! )
val headers = mapOf( "Authorization" to authToken )
val request = Request(verb = POST, room = room, server = server, endpoint = "claim_auth_token",
parameters = parameters, headers = headers, isAuthRequired = false)
return send(request).map { authToken }
}
fun deleteAuthToken(room: String, server: String): Promise<Unit, Exception> {
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<Long, Exception> {
val base64EncodedFile = encodeBytes(file)
val parameters = mapOf( "file" to base64EncodedFile )
val request = Request(verb = POST, room = room, server = server, endpoint = "files", parameters = parameters)
return send(request).map { json ->
(json["result"] as? Number)?.toLong() ?: throw Error.ParsingFailed
}
}
fun download(file: Long, room: String, server: String): Promise<ByteArray, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "files/$file")
return send(request).map { json ->
val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed
decode(base64EncodedFile) ?: throw Error.ParsingFailed
}
}
// endregion
// region Sending
fun send(message: OpenGroupMessageV2, room: String, server: String): Promise<OpenGroupMessageV2, Exception> {
val signedMessage = message.sign() ?: return Promise.ofFail(Error.SigningFailed)
val jsonMessage = signedMessage.toJSON()
val request = Request(verb = POST, room = room, server = server, endpoint = "messages", parameters = jsonMessage)
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessage = json["message"] as? Map<String, Any>
?: throw Error.ParsingFailed
val result = OpenGroupMessageV2.fromJSON(rawMessage) ?: throw Error.ParsingFailed
val storage = MessagingModuleConfiguration.shared.storage
storage.addReceivedMessageTimestamp(result.sentTimestamp)
result
}
}
// endregion
// region Messages
fun getMessages(room: String, server: String): Promise<List<OpenGroupMessageV2>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastMessageServerID(room, server)?.let { lastId ->
queryParameters += "from_server_id" to lastId.toString()
}
val request = Request(verb = GET, room = room, server = server, endpoint = "messages", queryParameters = queryParameters)
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessages = json["messages"] as? List<Map<String, Any>>
?: throw Error.ParsingFailed
parseMessages(room, server, rawMessages)
}
}
private fun parseMessages(room: String, server: String, rawMessages: List<Map<*, *>>): List<OpenGroupMessageV2> {
val messages = rawMessages.mapNotNull { json ->
json as Map<String, Any>
try {
val message = OpenGroupMessageV2.fromJSON(json) ?: return@mapNotNull null
if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null
val sender = message.sender
val data = decode(message.base64EncodedData)
val signature = decode(message.base64EncodedSignature)
val publicKey = Hex.fromStringCondensed(sender.removing05PrefixIfNeeded())
val isValid = curve.verifySignature(publicKey, data, signature)
if (!isValid) {
Log.d("Loki", "Ignoring message with invalid signature.")
return@mapNotNull null
}
message
} catch (e: Exception) {
null
}
}
return messages
}
// endregion
// region Message Deletion
@JvmStatic
fun deleteMessage(serverID: Long, room: String, server: String): Promise<Unit, Exception> {
val request = Request(verb = DELETE, room = room, server = server, endpoint = "messages/$serverID")
return send(request).map {
Log.d("Loki", "Message deletion successful.")
}
}
fun getDeletedMessages(room: String, server: String): Promise<List<MessageDeletion>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastDeletionServerID(room, server)?.let { last ->
queryParameters["from_server_id"] = last.toString()
}
val request = Request(verb = GET, room = room, server = server, endpoint = "deleted_messages", queryParameters = queryParameters)
return send(request).map { json ->
val type = TypeFactory.defaultInstance().constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["ids"])
val serverIDs = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type) ?: throw Error.ParsingFailed
val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0
val serverID = serverIDs.maxByOrNull {it.id } ?: MessageDeletion.empty
if (serverID.id > lastMessageServerId) {
storage.setLastDeletionServerID(room, server, serverID.id)
}
serverIDs
}
}
// endregion
// region Moderation
private fun handleModerators(serverRoomId: String, moderatorList: List<String>) {
moderators[serverRoomId] = moderatorList.toMutableSet()
}
fun getModerators(room: String, server: String): Promise<List<String>, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "moderators")
return send(request).map { json ->
@Suppress("UNCHECKED_CAST") val moderatorsJson = json["moderators"] as? List<String>
?: throw Error.ParsingFailed
val id = "$server.$room"
handleModerators(id, moderatorsJson)
moderatorsJson
}
}
@JvmStatic
fun ban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val parameters = mapOf( "public_key" to publicKey )
val request = Request(verb = POST, room = room, server = server, endpoint = "block_list", parameters = parameters)
return send(request).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val parameters = mapOf( "public_key" to publicKey )
val request = Request(verb = POST, room = room, server = server, endpoint = "ban_and_delete_all", parameters = parameters)
return send(request).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun unban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val request = Request(verb = DELETE, room = room, server = server, endpoint = "block_list/$publicKey")
return send(request).map {
Log.d("Loki", "Unbanned user: $publicKey from: $server.$room")
}
}
@JvmStatic
fun isUserModerator(publicKey: String, room: String, server: String): Boolean =
moderators["$server.$room"]?.contains(publicKey) ?: false
// endregion
// region General
@Suppress("UNCHECKED_CAST")
fun compactPoll(rooms: List<String>, server: String): Promise<Map<String, CompactPollResult>, 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<String> ?: return@mapNotNull null
handleModerators("$server.$roomID", moderators)
// Deletions
val type = TypeFactory.defaultInstance().constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["deletions"])
val deletions = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type) ?: throw Error.ParsingFailed
// Messages
val rawMessages = json["messages"] as? List<Map<String, Any>> ?: return@mapNotNull null
val messages = parseMessages(roomID, server, rawMessages)
roomID to CompactPollResult(
messages = messages,
deletions = deletions,
moderators = moderators
)
}.toMap()
}
}
fun getDefaultRoomsIfNeeded(): Promise<List<DefaultGroup>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey)
return getAllRooms(defaultServer).map { groups ->
val earlyGroups = groups.map { group ->
DefaultGroup(group.id, group.name, null)
}
// See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results
defaultRooms.replayCache.firstOrNull()?.let { replayed ->
if (replayed.none { it.image?.isNotEmpty() == true}) {
defaultRooms.tryEmit(earlyGroups)
}
}
val images = groups.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<Info, Exception> {
val request = Request(verb = GET, room = null, server = server, endpoint = "rooms/$room", isAuthRequired = false)
return send(request).map { json ->
val rawRoom = json["room"] as? Map<*, *> ?: throw Error.ParsingFailed
val id = rawRoom["id"] as? String ?: throw Error.ParsingFailed
val name = rawRoom["name"] as? String ?: throw Error.ParsingFailed
val imageID = rawRoom["image_id"] as? String
Info(id = id, name = name, imageID = imageID)
}
}
fun getAllRooms(server: String): Promise<List<Info>, Exception> {
val request = Request(verb = GET, room = null, server = server, endpoint = "rooms", isAuthRequired = false)
return send(request).map { json ->
val rawRooms = json["rooms"] as? List<Map<*, *>> ?: throw Error.ParsingFailed
rawRooms.mapNotNull {
val roomJson = it as? Map<*, *> ?: return@mapNotNull null
val id = roomJson["id"] as? String ?: return@mapNotNull null
val name = roomJson["name"] as? String ?: return@mapNotNull null
val imageID = roomJson["image_id"] as? String
Info(id, name, imageID)
}
}
}
fun getMemberCount(room: String, server: String): Promise<Int, Exception> {
val request = Request(verb = GET, room = room, server = server, endpoint = "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
}

View File

@ -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<List<DefaultGroup>>(replay = 1)
private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>()
private var hasUpdatedLastOpenDate = false
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
private val timeSinceLastOpen by lazy {
val context = MessagingModuleConfiguration.shared.context
val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context)
val now = System.currentTimeMillis()
now - lastOpenDate
}
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<PinnedMessage> = emptyList(),
val admin: Boolean = false,
val globalAdmin: Boolean = false,
val admins: List<String> = emptyList(),
val hiddenAdmins: List<String> = emptyList(),
val moderator: Boolean = false,
val globalModerator: Boolean = false,
val moderators: List<String> = emptyList(),
val hiddenModerators: List<String> = 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<T>(
val request: BatchRequest,
val endpoint: Endpoint,
val responseType: TypeReference<T>
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class BatchRequest(
val method: HTTP.Verb,
val path: String,
val headers: Map<String, String> = emptyMap(),
val json: Map<String, Any>? = null,
val b64: String? = null,
val bytes: ByteArray? = null,
)
data class BatchResponse<T>(
val endpoint: Endpoint,
val code: Int,
val headers: Map<String, String>,
val body: T?
)
data class Capabilities(
val capabilities: List<String> = emptyList(),
val missing: List<String> = 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<String>? = null,
val whisperMods: Boolean? = null,
val files: List<String>? = 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<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
/**
* Always `true` under normal circumstances. You might want to disable
* this when running over Lokinet.
*/
val useOnionRouting: Boolean = true
)
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<ByteArray, Exception> {
return send(request).map { response ->
response.body ?: throw Error.ParsingFailed
}
}
private fun getResponseBodyJson(request: Request): Promise<Map<*, *>, Exception> {
return send(request).map {
JsonUtil.fromJson(it.body, Map::class.java)
}
}
private fun send(request: Request): Promise<OnionResponse, 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.value)
if (request.verb == GET) {
for ((key, value) in request.queryParameters) {
urlBuilder.addQueryParameter(key, value)
}
}
fun execute(): Promise<OnionResponse, Exception> {
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<ByteArray, Exception> {
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<Long, Exception> {
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<ByteArray, Exception> {
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<String>? = null,
whisperMods: Boolean? = null,
fileIds: List<String>? = null
): Promise<OpenGroupMessage, Exception> {
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<String, Any>
?: 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<List<OpenGroupMessage>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastMessageServerID(room, server)?.let { lastId ->
queryParameters += "from_server_id" to lastId.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = Endpoint.RoomMessage(room),
queryParameters = queryParameters
)
return getResponseBodyJson(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessages =
json["messages"] as? List<Map<String, Any>>
?: throw Error.ParsingFailed
parseMessages(room, server, rawMessages)
}
}
private fun parseMessages(
room: String,
server: String,
rawMessages: List<Map<*, *>>
): List<OpenGroupMessage> {
val messages = rawMessages.mapNotNull { json ->
json as Map<String, Any>
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<Unit, Exception> {
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<List<MessageDeletion>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastDeletionServerID(room, server)?.let { last ->
queryParameters["from_server_id"] = last.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = 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<List<MessageDeletion>>(idsAsString, type)
?: throw Error.ParsingFailed
val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0
val serverID = serverIDs.maxByOrNull { it.id } ?: MessageDeletion.empty
if (serverID.id > lastMessageServerId) {
storage.setLastDeletionServerID(room, server, serverID.id)
}
serverIDs
}
}
// endregion
// region Moderation
@JvmStatic
fun ban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
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<Unit, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
method = POST,
path = "/user/$publicKey/ban",
json = mapOf("rooms" to listOf(room))
),
endpoint = Endpoint.UserBan(publicKey),
responseType = object: TypeReference<Any>(){}
),
BatchRequestInfo(
request = BatchRequest(DELETE, "/room/$room/all/$publicKey"),
endpoint = Endpoint.RoomDeleteMessages(room, publicKey),
responseType = object: TypeReference<Any>(){}
)
)
return sequentialBatch(server, requests).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun unban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val request =
Request(verb = DELETE, room = room, server = server, endpoint = 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<String>,
server: String
): Promise<List<BatchResponse<*>>, 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<*>>(
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/capabilities"
),
endpoint = Endpoint.Capabilities,
responseType = object : TypeReference<Capabilities>(){}
)
)
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<RoomPollInfo>(){}
)
)
requests.add(
if (shouldRetrieveRecentMessages) {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/messages/recent"
),
endpoint = Endpoint.RoomMessagesRecent(room),
responseType = object : TypeReference<List<Message>>(){}
)
} 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<List<Message>>(){}
)
}
)
}
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<List<DirectMessage>>() {}
)
} else {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/inbox/since/$lastInboxMessageId"
),
endpoint = Endpoint.InboxSince(lastInboxMessageId),
responseType = object : TypeReference<List<DirectMessage>>() {}
)
}
)
requests.add(
if (lastOutboxMessageId == null) {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/outbox"
),
endpoint = Endpoint.Outbox,
responseType = object : TypeReference<List<DirectMessage>>() {}
)
} else {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/outbox/since/$lastOutboxMessageId"
),
endpoint = Endpoint.OutboxSince(lastOutboxMessageId),
responseType = object : TypeReference<List<DirectMessage>>() {}
)
}
)
}
return parallelBatch(server, requests)
}
private fun parallelBatch(
server: String,
requests: MutableList<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, 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<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, 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<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, 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<String, String>,
body = if (code in 200..299) {
JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let {
JsonUtil.fromJson(it, requests[idx].responseType)
}
} else null
)
}
}
}
fun getDefaultServerCapabilities(): Promise<Capabilities, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey)
return getCapabilities(defaultServer).map { capabilities ->
storage.setServerCapabilities(defaultServer, capabilities.capabilities)
capabilities
}
}
fun getDefaultRoomsIfNeeded(): Promise<List<DefaultGroup>, 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<RoomInfo, Exception> {
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<List<RoomInfo>, 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<Int, Exception> {
return getRoomInfo(room, server).map { info ->
val storage = MessagingModuleConfiguration.shared.storage
storage.setUserCount(room, server, info.activeUsers)
info.activeUsers
}
}
fun getCapabilities(server: String): Promise<Capabilities, Exception> {
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<Pair<Capabilities, RoomInfo>, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/capabilities"
),
endpoint = Endpoint.Capabilities,
responseType = object : TypeReference<Capabilities>(){}
),
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room"
),
endpoint = Endpoint.Room(room),
responseType = object : TypeReference<RoomInfo>(){}
)
)
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<DirectMessage, Exception> {
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
}

View File

@ -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<String, Any>): 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<String, Any> {
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)
}
}

View File

@ -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<String, Any>): 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<String, Any> {
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)
}
}

View File

@ -5,11 +5,15 @@ import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.Box import com.goterl.lazysodium.interfaces.Box
import com.goterl.lazysodium.interfaces.Sign 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.crypto.ecc.ECKeyPair
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.session.libsignal.utilities.removing05PrefixIfNeeded import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
object MessageDecrypter { object MessageDecrypter {
@ -25,7 +29,7 @@ object MessageDecrypter {
*/ */
public fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair<ByteArray, String> { public fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair<ByteArray, String> {
val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize()
val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded()) val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded())
val signatureSize = Sign.BYTES val signatureSize = Sign.BYTES
val ed25519PublicKeySize = Sign.PUBLICKEYBYTES val ed25519PublicKeySize = Sign.PUBLICKEYBYTES
@ -35,9 +39,9 @@ object MessageDecrypter {
sodium.cryptoBoxSealOpen(plaintextWithMetadata, ciphertext, ciphertext.size.toLong(), recipientX25519PublicKey, recipientX25519PrivateKey) sodium.cryptoBoxSealOpen(plaintextWithMetadata, ciphertext, ciphertext.size.toLong(), recipientX25519PublicKey, recipientX25519PrivateKey)
} catch (exception: Exception) { } catch (exception: Exception) {
Log.d("Loki", "Couldn't decrypt message due to error: $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 // 2. ) Get the message parts
val signature = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - signatureSize until plaintextWithMetadata.size) val signature = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - signatureSize until plaintextWithMetadata.size)
val senderED25519PublicKey = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize) until plaintextWithMetadata.size - signatureSize) val senderED25519PublicKey = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize) until plaintextWithMetadata.size - signatureSize)
@ -46,15 +50,62 @@ object MessageDecrypter {
val verificationData = (plaintext + senderED25519PublicKey + recipientX25519PublicKey) val verificationData = (plaintext + senderED25519PublicKey + recipientX25519PublicKey)
try { try {
val isValid = sodium.cryptoSignVerifyDetached(signature, verificationData, verificationData.size, senderED25519PublicKey) val isValid = sodium.cryptoSignVerifyDetached(signature, verificationData, verificationData.size, senderED25519PublicKey)
if (!isValid) { throw MessageReceiver.Error.InvalidSignature } if (!isValid) { throw Error.InvalidSignature }
} catch (exception: Exception) { } catch (exception: Exception) {
Log.d("Loki", "Couldn't verify message signature due to error: $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 // 4. ) Get the sender's X25519 public key
val senderX25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) val senderX25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
sodium.convertPublicKeyEd25519ToCurve25519(senderX25519PublicKey, senderED25519PublicKey) 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<ByteArray, String> {
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)
} }
} }

View File

@ -6,9 +6,11 @@ import com.goterl.lazysodium.interfaces.Box
import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.interfaces.Sign
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.MessageSender.Error 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.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.removing05PrefixIfNeeded import org.session.libsignal.utilities.removingIdPrefixIfNeeded
object MessageEncrypter { object MessageEncrypter {
@ -24,7 +26,7 @@ object MessageEncrypter {
*/ */
internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray {
val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair 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 verificationData = plaintext + userED25519KeyPair.publicKey.asBytes + recipientX25519PublicKey
val signature = ByteArray(Sign.BYTES) val signature = ByteArray(Sign.BYTES)
@ -46,4 +48,33 @@ object MessageEncrypter {
return ciphertext 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
}
} }

View File

@ -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.TypingIndicator
import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
object MessageReceiver { object MessageReceiver {
@ -31,6 +34,7 @@ object MessageReceiver {
object SelfSend: Error("Message addressed at self.") object SelfSend: Error("Message addressed at self.")
object InvalidGroupPublicKey: Error("Invalid group public key.") object InvalidGroupPublicKey: Error("Invalid group public key.")
object NoGroupKeyPair: Error("Missing group key pair.") object NoGroupKeyPair: Error("Missing group key pair.")
object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.")
internal val isRetryable: Boolean = when (this) { internal val isRetryable: Boolean = when (this) {
is DuplicateMessage, is InvalidMessage, is UnknownMessage, is DuplicateMessage, is InvalidMessage, is UnknownMessage,
@ -40,7 +44,13 @@ object MessageReceiver {
} }
} }
internal fun parse(data: ByteArray, openGroupServerID: Long?): Pair<Message, SignalServiceProtos.Content> { internal fun parse(
data: ByteArray,
openGroupServerID: Long?,
isOutgoing: Boolean? = null,
otherBlindedPublicKey: String? = null,
openGroupPublicKey: String? = null,
): Pair<Message, SignalServiceProtos.Content> {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey() val userPublicKey = storage.getUserPublicKey()
val isOpenGroupMessage = (openGroupServerID != null) val isOpenGroupMessage = (openGroupServerID != null)
@ -59,11 +69,24 @@ object MessageReceiver {
} else { } else {
when (envelope.type) { when (envelope.type) {
SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> {
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 userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair()
val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), userX25519KeyPair) val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), userX25519KeyPair)
plaintext = decryptionResult.first plaintext = decryptionResult.first
sender = decryptionResult.second sender = decryptionResult.second
} }
}
SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE -> { SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE -> {
val hexEncodedGroupPublicKey = envelope.source val hexEncodedGroupPublicKey = envelope.source
if (hexEncodedGroupPublicKey == null || !MessagingModuleConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey)) { if (hexEncodedGroupPublicKey == null || !MessagingModuleConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey)) {
@ -118,8 +141,9 @@ object MessageReceiver {
VisibleMessage.fromProto(proto) ?: run { VisibleMessage.fromProto(proto) ?: run {
throw Error.UnknownMessage 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 // Ignore self send if needed
if (!message.isSelfSendValid && sender == userPublicKey) { if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) {
throw Error.SelfSend throw Error.SelfSend
} }
// Guard against control messages in open groups // Guard against control messages in open groups

View File

@ -1,5 +1,6 @@
package org.session.libsession.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
import com.goterl.lazysodium.utils.KeyPair
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration 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.Profile
import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.Quote
import org.session.libsession.messaging.messages.visible.VisibleMessage 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.open_groups.OpenGroupMessageV2 import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.messaging.utilities.MessageWrapper 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.RawResponsePromise
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeMessage 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.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.defaultRequiresAuth
import org.session.libsignal.utilities.hasNamespaces import org.session.libsignal.utilities.hasNamespaces
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview
@ -62,10 +67,10 @@ object MessageSender {
// Convenience // Convenience
fun send(message: Message, destination: Destination): Promise<Unit, Exception> { fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
if (destination is Destination.OpenGroupV2) { return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) {
return sendToOpenGroupDestination(destination, message) sendToOpenGroupDestination(destination, message)
} else { } else {
return sendToSnodeDestination(destination, message) sendToSnodeDestination(destination, message)
} }
} }
@ -96,7 +101,7 @@ object MessageSender {
when (destination) { when (destination) {
is Destination.Contact -> message.recipient = destination.publicKey is Destination.Contact -> message.recipient = destination.publicKey
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey 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 // Validate the message
if (!message.isValid()) { throw Error.InvalidMessage } if (!message.isValid()) { throw Error.InvalidMessage }
@ -127,14 +132,13 @@ object MessageSender {
// Serialize the protobuf // Serialize the protobuf
val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray())
// Encrypt the serialized protobuf // Encrypt the serialized protobuf
val ciphertext: ByteArray val ciphertext = when (destination) {
when (destination) { is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey)
is Destination.Contact -> ciphertext = MessageEncrypter.encrypt(plaintext, destination.publicKey)
is Destination.ClosedGroup -> { is Destination.ClosedGroup -> {
val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! 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 // Wrap the result
val kind: SignalServiceProtos.Envelope.Type val kind: SignalServiceProtos.Envelope.Type
@ -157,7 +161,7 @@ object MessageSender {
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE
senderPublicKey = destination.groupPublicKey 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) val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
// Send the result // Send the result
@ -174,7 +178,7 @@ object MessageSender {
namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises ->
var isSuccess = false var isSuccess = false
val promiseCount = promises.size val promiseCount = promises.size
var errorCount = AtomicInteger(0) val errorCount = AtomicInteger(0)
promises.forEach { promise: RawResponsePromise -> promises.forEach { promise: RawResponsePromise ->
promise.success { promise.success {
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
@ -217,19 +221,40 @@ object MessageSender {
if (message.sentTimestamp == null) { if (message.sentTimestamp == null) {
message.sentTimestamp = System.currentTimeMillis() message.sentTimestamp = System.currentTimeMillis()
} }
message.sender = storage.getUserPublicKey() val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
var serverCapabilities = listOf<String>()
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) // Set the failure handler (need it here already for precondition failure handling)
fun handleFailure(error: Exception) { fun handleFailure(error: Exception) {
handleFailedMessageSend(message, error) handleFailedMessageSend(message, error)
deferred.reject(error) deferred.reject(error)
} }
try { try {
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 // Attach the user's profile if needed
if (message is VisibleMessage) { if (message is VisibleMessage) {
val displayName = storage.getUserDisplayName()!! val displayName = storage.getUserDisplayName()!!
@ -241,18 +266,22 @@ object MessageSender {
message.profile = Profile(displayName) message.profile = Profile(displayName)
} }
} }
when (destination) {
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 // Validate the message
if (message !is VisibleMessage || !message.isValid()) { if (message !is VisibleMessage || !message.isValid()) {
throw Error.InvalidMessage throw Error.InvalidMessage
} }
val proto = message.toProto()!! val messageBody = message.toProto()?.toByteArray()!!
val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody)
val openGroupMessage = OpenGroupMessageV2( val openGroupMessage = OpenGroupMessage(
sender = message.sender, sender = message.sender,
sentTimestamp = message.sentTimestamp!!, sentTimestamp = message.sentTimestamp!!,
base64EncodedData = Base64.encodeBytes(plaintext), 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 message.openGroupServerMessageID = it.serverID
handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp) handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp)
deferred.resolve(Unit) deferred.resolve(Unit)
@ -260,6 +289,29 @@ object MessageSender {
handleFailure(it) 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) { } catch (exception: Exception) {
handleFailure(exception) handleFailure(exception)
@ -273,7 +325,7 @@ object MessageSender {
val userPublicKey = storage.getUserPublicKey()!! val userPublicKey = storage.getUserPublicKey()!!
// Ignore future self-sends // Ignore future self-sends
storage.addReceivedMessageTimestamp(message.sentTimestamp!!) 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) { if (openGroupSentTimestamp != -1L && message is VisibleMessage) {
storage.addReceivedMessageTimestamp(openGroupSentTimestamp) storage.addReceivedMessageTimestamp(openGroupSentTimestamp)
storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!) storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!)
@ -286,19 +338,19 @@ object MessageSender {
storage.setMessageServerHash(messageID, it) storage.setMessageServerHash(messageID, it)
} }
// Track the open group server message ID // Track the open group server message ID
if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) { if (message.openGroupServerMessageID != null && destination is Destination.LegacyOpenGroup) {
val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray()) val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.roomToken}".toByteArray())
val threadID = storage.getThreadId(Address.fromSerialized(encoded)) val threadID = storage.getThreadId(Address.fromSerialized(encoded))
if (threadID != null && threadID >= 0) { if (threadID != null && threadID >= 0) {
storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage())
} }
} }
// Mark the message as sent // Mark the message as sent
storage.markAsSent(message.sentTimestamp!!, message.sender?:userPublicKey) storage.markAsSent(message.sentTimestamp!!, userPublicKey)
storage.markUnidentified(message.sentTimestamp!!, message.sender?:userPublicKey) storage.markUnidentified(message.sentTimestamp!!, userPublicKey)
// Start the disappearing messages timer if needed // Start the disappearing messages timer if needed
if (message is VisibleMessage && !isSyncMessage) { 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: // Sync the message if:

View File

@ -21,7 +21,7 @@ import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.hexEncodedPublicKey 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.Hex
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.Log 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<String>, targetUser: String? = null, force: Boolean = true): Promise<Unit, Exception>? { fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKeyPair, targetMembers: Collection<String>, targetUser: String? = null, force: Boolean = true): Promise<Unit, Exception>? {
val destination = targetUser ?: GroupUtil.doubleEncodeGroupID(groupPublicKey) val destination = targetUser ?: GroupUtil.doubleEncodeGroupID(groupPublicKey)
val proto = SignalServiceProtos.KeyPair.newBuilder() 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()) proto.privateKey = ByteString.copyFrom(newKeyPair.privateKey.serialize())
val plaintext = proto.build().toByteArray() val plaintext = proto.build().toByteArray()
val wrappers = targetMembers.map { publicKey -> val wrappers = targetMembers.map { publicKey ->
@ -326,7 +326,7 @@ fun MessageSender.sendLatestEncryptionKeyPair(publicKey: String, groupPublicKey:
?: storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return ?: storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return
// Send it // Send it
val proto = SignalServiceProtos.KeyPair.newBuilder() 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()) proto.privateKey = ByteString.copyFrom(encryptionKeyPair.privateKey.serialize())
val plaintext = proto.build().toByteArray() val plaintext = proto.build().toByteArray()
val ciphertext = MessageEncrypter.encrypt(plaintext, publicKey) val ciphertext = MessageEncrypter.encrypt(plaintext, publicKey)

View File

@ -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.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel 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.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address 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.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional 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 org.session.libsignal.utilities.toHexString
import java.security.MessageDigest import java.security.MessageDigest
import java.util.LinkedList import java.util.LinkedList
@ -153,7 +156,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer) 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) { for (openGroup in message.openGroups) {
if (allV2OpenGroups.contains(openGroup)) continue if (allV2OpenGroups.contains(openGroup)) continue
Log.d("OpenGroup", "All open groups doesn't contain $openGroup") Log.d("OpenGroup", "All open groups doesn't contain $openGroup")
@ -216,8 +219,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
// Get or create thread // Get or create thread
// FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet // 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. // exist. This is intentional, but it's very non-obvious.
val threadID = storage.getOrCreateThreadIdFor(message.syncTarget val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID)
?: messageSender!!, message.groupPublicKey, openGroupID)
if (threadID < 0) { 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 // 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 throw MessageReceiver.Error.NoThread
@ -226,7 +228,9 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false) val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false)
if (runProfileUpdate) { if (runProfileUpdate) {
val profile = message.profile 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 profileManager = SSKEnvironment.shared.profileManager
val name = profile.displayName!! val name = profile.displayName!!
if (name.isNotEmpty()) { if (name.isNotEmpty()) {
@ -395,7 +399,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr
val plaintext = MessageDecrypter.decrypt(encryptedKeyPair, userKeyPair).first val plaintext = MessageDecrypter.decrypt(encryptedKeyPair, userKeyPair).first
// Parse it // Parse it
val proto = SignalServiceProtos.KeyPair.parseFrom(plaintext) 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 // Store it if needed
val closedGroupEncryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(groupPublicKey) val closedGroupEncryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(groupPublicKey)
if (closedGroupEncryptionKeyPairs.contains(keyPair)) { if (closedGroupEncryptionKeyPairs.contains(keyPair)) {

View File

@ -7,6 +7,7 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.Version
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
@ -38,12 +39,12 @@ object PushNotificationAPI {
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = json["code"] as? Int val code = response.info["code"] as? Int
if (code != null && code != 0) { if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, false) TextSecurePreferences.setIsUsingFCM(context, false)
} else { } 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 -> }.fail { exception ->
Log.d("Loki", "Couldn't disable FCM due to error: ${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 body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = json["code"] as? Int val code = response.info["code"] as? Int
if (code != null && code != 0) { if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, true) TextSecurePreferences.setIsUsingFCM(context, true)
TextSecurePreferences.setFCMToken(context, token) TextSecurePreferences.setFCMToken(context, token)
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis()) TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
} else { } 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 -> }.fail { exception ->
Log.d("Loki", "Couldn't register for FCM due to error: ${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 body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body) val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) { retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json -> OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = json["code"] as? Int val code = response.info["code"] as? Int
if (code == null || code == 0) { 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 -> }.fail { exception ->
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.") Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")

View File

@ -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<Unit, Exception> {
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<OpenGroupApi.Message>)
}
is Endpoint.RoomMessagesSince -> {
handleMessages(server, response.endpoint.roomToken, response.body as List<OpenGroupApi.Message>)
}
is Endpoint.Inbox, is Endpoint.InboxSince -> {
handleDirectMessages(server, false, response.body as List<OpenGroupApi.DirectMessage>)
}
is Endpoint.Outbox, is Endpoint.OutboxSince -> {
handleDirectMessages(server, true, response.body as List<OpenGroupApi.DirectMessage>)
}
}
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<OpenGroupApi.Message>
) {
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<OpenGroupApi.DirectMessage>
) {
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<String, BlindedIdMapping>()
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<OpenGroupMessage>) {
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<Long>) {
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))
}
}
}
}

View File

@ -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<Unit, Exception> {
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<OpenGroupMessageV2>, 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<OpenGroupAPIV2.MessageDeletion>) {
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))
}
}
}
}

View File

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

View File

@ -1,12 +1,13 @@
package org.session.libsession.snode package org.session.libsession.snode
import nl.komponents.kovenant.Deferred
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all import nl.komponents.kovenant.all
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.functional.map
import okhttp3.Request 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
import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsession.utilities.getBodyForOnionRequest 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 const val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
// endregion // 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.") : Exception("HTTP request failed at destination ($destination) with status code $statusCode.")
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") 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 ThreadUtils.queue { // No need to block the shared context for this
val url = "${snode.address}:${snode.port}/get_stats/v1" val url = "${snode.address}:${snode.port}/get_stats/v1"
try { 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 val version = json["version"] as? String
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue } if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue }
if (version >= "2.0.7") { if (version >= "2.0.7") {
@ -207,29 +210,33 @@ object OnionRequestAPI {
} }
OnionRequestAPI.guardSnodes = guardSnodes OnionRequestAPI.guardSnodes = guardSnodes
fun getPath(paths: List<Path>): Path { fun getPath(paths: List<Path>): Path {
if (snodeToExclude != null) { return if (snodeToExclude != null) {
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement() paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
} else { } else {
return paths.getRandomElement() paths.getRandomElement()
} }
} }
if (paths.count() >= targetPathCount) { when {
paths.count() >= targetPathCount -> {
return Promise.of(getPath(paths)) return Promise.of(getPath(paths))
} else if (paths.isNotEmpty()) { }
if (paths.any { !it.contains(snodeToExclude) }) { paths.isNotEmpty() -> {
return if (paths.any { !it.contains(snodeToExclude) }) {
buildPaths(paths) // Re-build paths in the background buildPaths(paths) // Re-build paths in the background
return Promise.of(getPath(paths)) Promise.of(getPath(paths))
} else { } else {
return buildPaths(paths).map { newPaths -> buildPaths(paths).map { newPaths ->
getPath(newPaths) getPath(newPaths)
} }
} }
} else { }
else -> {
return buildPaths(listOf()).map { newPaths -> return buildPaths(listOf()).map { newPaths ->
getPath(newPaths) getPath(newPaths)
} }
} }
} }
}
private fun dropGuardSnode(snode: Snode) { private fun dropGuardSnode(snode: Snode) {
guardSnodes = guardSnodes.filter { it != snode }.toSet() guardSnodes = guardSnodes.filter { it != snode }.toSet()
@ -268,7 +275,11 @@ object OnionRequestAPI {
/** /**
* Builds an onion around `payload` and returns the result. * Builds an onion around `payload` and returns the result.
*/ */
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise<OnionBuildingResult, Exception> { private fun buildOnionForDestination(
payload: ByteArray,
destination: Destination,
version: Version
): Promise<OnionBuildingResult, Exception> {
lateinit var guardSnode: Snode lateinit var guardSnode: Snode
lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination
lateinit var encryptionResult: EncryptionResult lateinit var encryptionResult: EncryptionResult
@ -279,19 +290,19 @@ object OnionRequestAPI {
return getPath(snodeToExclude).bind { path -> return getPath(snodeToExclude).bind { path ->
guardSnode = path.first() guardSnode = path.first()
// Encrypt in reverse order, i.e. the destination 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 destinationSymmetricKey = r.symmetricKey
// Recursively encrypt the layers of the onion (again in reverse order) // Recursively encrypt the layers of the onion (again in reverse order)
encryptionResult = r encryptionResult = r
@Suppress("NAME_SHADOWING") var path = path @Suppress("NAME_SHADOWING") var path = path
var rhs = destination var rhs = destination
fun addLayer(): Promise<EncryptionResult, Exception> { fun addLayer(): Promise<EncryptionResult, Exception> {
if (path.isEmpty()) { return if (path.isEmpty()) {
return Promise.of(encryptionResult) Promise.of(encryptionResult)
} else { } else {
val lhs = Destination.Snode(path.last()) val lhs = Destination.Snode(path.last())
path = path.dropLast(1) path = path.dropLast(1)
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r -> OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r ->
encryptionResult = r encryptionResult = r
rhs = lhs rhs = lhs
addLayer() addLayer()
@ -306,16 +317,20 @@ object OnionRequestAPI {
/** /**
* Sends an onion request to `destination`. Builds new paths as needed. * Sends an onion request to `destination`. Builds new paths as needed.
*/ */
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>): Promise<Map<*, *>, Exception> { private fun sendOnionRequest(
val deferred = deferred<Map<*, *>, Exception>() destination: Destination,
payload: ByteArray,
version: Version
): Promise<OnionResponse, Exception> {
val deferred = deferred<OnionResponse, Exception>()
var guardSnode: Snode? = null var guardSnode: Snode? = null
buildOnionForDestination(payload, destination).success { result -> buildOnionForDestination(payload, destination, version).success { result ->
guardSnode = result.guardSnode guardSnode = result.guardSnode
val nonNullGuardSnode = result.guardSnode val nonNullGuardSnode = result.guardSnode
val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2"
val finalEncryptionResult = result.finalEncryptionResult val finalEncryptionResult = result.finalEncryptionResult
val onion = finalEncryptionResult.ciphertext 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.") Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
} }
@Suppress("NAME_SHADOWING") val parameters = mapOf( @Suppress("NAME_SHADOWING") val parameters = mapOf(
@ -330,65 +345,8 @@ object OnionRequestAPI {
val destinationSymmetricKey = result.destinationSymmetricKey val destinationSymmetricKey = result.destinationSymmetricKey
ThreadUtils.queue { ThreadUtils.queue {
try { try {
val json = HTTP.execute(HTTP.Verb.POST, url, body) val response = HTTP.execute(HTTP.Verb.POST, url, body)
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@queue deferred.reject(Exception("Invalid JSON")) handleResponse(response, destinationSymmetricKey, destination, version, deferred)
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<Int>
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)
}
} catch (exception: Exception) { } catch (exception: Exception) {
deferred.reject(exception) deferred.reject(exception)
} }
@ -459,9 +417,19 @@ object OnionRequestAPI {
/** /**
* Sends an onion request to `snode`. Builds new paths as needed. * Sends an onion request to `snode`. Builds new paths as needed.
*/ */
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String? = null): Promise<Map<*, *>, Exception> { internal fun sendOnionRequest(
val payload = mapOf( "method" to method.rawValue, "params" to parameters ) method: Snode.Method,
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception -> parameters: Map<*, *>,
snode: Snode,
version: Version,
publicKey: String? = null
): Promise<OnionResponse, Exception> {
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) { val error = when (exception) {
is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)
is HTTPRequestFailedAtDestinationException -> 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. * `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<Map<*, *>, Exception> { fun sendOnionRequest(
val headers = request.getHeadersForOnionRequest() request: Request,
server: String,
x25519PublicKey: String,
version: Version = Version.V4
): Promise<OnionResponse, Exception> {
val url = request.url()
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 url = request.url()
val urlAsString = url.toString() val urlAsString = url.toString()
val host = url.host() val body = request.getBodyForOnionRequest() ?: "null"
val endpoint = when { val endpoint = when {
server.count() < urlAsString.count() -> urlAsString.substringAfter(server).removePrefix("/") server.count() < urlAsString.count() -> urlAsString.substringAfter(server)
else -> "" else -> ""
} }
val body = request.getBodyForOnionRequest() ?: "null" return if (version == Version.V4) {
val payload = mapOf( if (request.body() != null &&
"body" to body, headers.keys.find { it.equals("Content-Type", true) } == null) {
headers["Content-Type"] = "application/json"
}
val requestPayload = mapOf(
"endpoint" to endpoint, "endpoint" to endpoint,
"method" to request.method(), "method" to request.method(),
"headers" to headers "headers" to headers
) )
val destination = Destination.Server(host, target, x25519PublicKey, url.scheme(), url.port()) val requestData = JsonUtil.toJson(requestPayload).toByteArray()
return sendOnionRequest(destination, payload).recover { exception -> val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII)
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.") val suffixData = "e".toByteArray(Charsets.US_ASCII)
throw exception 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<OnionResponse, Exception>
) {
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<Int>
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 // 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
)

View File

@ -2,6 +2,7 @@ package org.session.libsession.snode
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import org.session.libsession.snode.OnionRequestAPI.Destination
import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM
import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsignal.utilities.toHexString 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. * 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<EncryptionResult, Exception> { internal fun encryptPayloadForDestination(
payload: ByteArray,
destination: Destination,
version: Version
): Promise<EncryptionResult, Exception> {
val deferred = deferred<EncryptionResult, Exception>() val deferred = deferred<EncryptionResult, Exception>()
ThreadUtils.queue { ThreadUtils.queue {
try { try {
val plaintext = if (version == Version.V4) {
payload
} else {
// Wrapping isn't needed for file server or open group onion requests // Wrapping isn't needed for file server or open group onion requests
when (destination) { when (destination) {
is OnionRequestAPI.Destination.Snode -> { is Destination.Snode -> encode(payload, mapOf("headers" to ""))
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key is Destination.Server -> payload
val payloadAsData = JsonUtil.toJson(payload).toByteArray() }
val plaintext = encode(payloadAsData, mapOf( "headers" to "" )) }
val result = AESGCM.encrypt(plaintext, snodeX25519PublicKey) 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) deferred.resolve(result)
}
is OnionRequestAPI.Destination.Server -> {
val plaintext = JsonUtil.toJson(payload).toByteArray()
val result = AESGCM.encrypt(plaintext, destination.x25519PublicKey)
deferred.resolve(result)
}
}
} catch (exception: Exception) { } catch (exception: Exception) {
deferred.reject(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. * 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<EncryptionResult, Exception> { internal fun encryptHop(lhs: Destination, rhs: Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
val deferred = deferred<EncryptionResult, Exception>() val deferred = deferred<EncryptionResult, Exception>()
ThreadUtils.queue { ThreadUtils.queue {
try { try {
val payload: MutableMap<String, Any> val payload: MutableMap<String, Any> = when (rhs) {
when (rhs) { is Destination.Snode -> {
is OnionRequestAPI.Destination.Snode -> { mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
} }
is OnionRequestAPI.Destination.Server -> { is Destination.Server -> {
payload = mutableMapOf( mutableMapOf(
"host" to rhs.host, "host" to rhs.host,
"target" to rhs.target, "target" to rhs.target,
"method" to "POST", "method" to "POST",
@ -80,13 +84,12 @@ object OnionRequestEncryption {
} }
} }
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
val x25519PublicKey: String val x25519PublicKey = when (lhs) {
when (lhs) { is Destination.Snode -> {
is OnionRequestAPI.Destination.Snode -> { lhs.snode.publicKeySet!!.x25519Key
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
} }
is OnionRequestAPI.Destination.Server -> { is Destination.Server -> {
x25519PublicKey = lhs.x25519PublicKey lhs.x25519PublicKey
} }
} }
val plaintext = encode(previousEncryptionResult.ciphertext, payload) val plaintext = encode(previousEncryptionResult.ciphertext, payload)

View File

@ -26,6 +26,7 @@ import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.Broadcaster
import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.Snode
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
@ -93,16 +94,26 @@ object SnodeAPI {
} }
// Internal API // Internal API
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String? = null, parameters: Map<String, Any>): RawResponsePromise { internal fun invoke(
method: Snode.Method,
snode: Snode,
parameters: Map<String, Any>,
publicKey: String? = null,
version: Version = Version.V3
): RawResponsePromise {
val url = "${snode.address}:${snode.port}/storage_rpc/v1" val url = "${snode.address}:${snode.port}/storage_rpc/v1"
if (useOnionRequests) {
return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey)
} else {
val deferred = deferred<Map<*, *>, Exception>() val deferred = deferred<Map<*, *>, Exception>()
if (useOnionRequests) {
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 {
ThreadUtils.queue { ThreadUtils.queue {
val payload = mapOf( "method" to method.rawValue, "params" to parameters ) val payload = mapOf( "method" to method.rawValue, "params" to parameters )
try { 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) deferred.resolve(json)
} catch (exception: Exception) { } catch (exception: Exception) {
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
@ -114,8 +125,8 @@ object SnodeAPI {
deferred.reject(exception) deferred.reject(exception)
} }
} }
return deferred.promise
} }
return deferred.promise
} }
internal fun getRandomSnode(): Promise<Snode, Exception> { internal fun getRandomSnode(): Promise<Snode, Exception> {
@ -136,7 +147,12 @@ object SnodeAPI {
deferred<Snode, Exception>() deferred<Snode, Exception>()
ThreadUtils.queue { ThreadUtils.queue {
try { 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 intermediate = json["result"] as? Map<*, *>
val rawSnodes = intermediate?.get("service_node_states") as? List<*> val rawSnodes = intermediate?.get("service_node_states") as? List<*>
if (rawSnodes != null) { if (rawSnodes != null) {
@ -211,7 +227,7 @@ object SnodeAPI {
val promises = (1..validationCount).map { val promises = (1..validationCount).map {
getRandomSnode().bind { snode -> getRandomSnode().bind { snode ->
retryIfNeeded(maxRetryCount) { 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<Set<Snode>, Exception> { fun getSwarm(publicKey: String): Promise<Set<Snode>, Exception> {
val cachedSwarm = database.getSwarm(publicKey) val cachedSwarm = database.getSwarm(publicKey)
if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { return if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue
cachedSwarmCopy.addAll(cachedSwarm) cachedSwarmCopy.addAll(cachedSwarm)
return task { cachedSwarmCopy } task { cachedSwarmCopy }
} else { } else {
val parameters = mapOf( "pubKey" to publicKey ) val parameters = mapOf( "pubKey" to publicKey )
return getRandomSnode().bind { getRandomSnode().bind {
invoke(Snode.Method.GetSwarm, it, publicKey, parameters) invoke(Snode.Method.GetSwarm, it, parameters, publicKey)
}.map { }.map {
parseSnodes(it).toSet() parseSnodes(it).toSet()
}.success { }.success {
@ -329,7 +345,7 @@ object SnodeAPI {
} }
// Make the request // Make the request
return invoke(Snode.Method.GetMessages, snode, publicKey, parameters) return invoke(Snode.Method.GetMessages, snode, parameters, publicKey)
} }
fun getMessages(publicKey: String): MessageListPromise { fun getMessages(publicKey: String): MessageListPromise {
@ -341,7 +357,7 @@ object SnodeAPI {
} }
private fun getNetworkTime(snode: Snode): Promise<Pair<Snode,Long>, Exception> { private fun getNetworkTime(snode: Snode): Promise<Pair<Snode,Long>, 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 val timestamp = rawResponse["timestamp"] as? Long ?: -1
snode to timestamp snode to timestamp
} }
@ -375,7 +391,7 @@ object SnodeAPI {
parameters["namespace"] = namespace parameters["namespace"] = namespace
} }
getSingleTargetSnode(destination).bind { snode -> 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, "messages" to serverHashes,
"signature" to Base64.encodeBytes(signature) "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<String, Any> ?: return@map mapOf() val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return@map mapOf()
val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) ->
val json = rawJSON as? Map<String, Any> ?: return@mapNotNull null val json = rawJSON as? Map<String, Any> ?: return@mapNotNull null
@ -466,7 +482,7 @@ object SnodeAPI {
"timestamp" to timestamp, "timestamp" to timestamp,
"signature" to Base64.encodeBytes(signature) "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) rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse)
}.fail { e -> }.fail { e ->
Log.e("Loki", "Failed to clear data", e) Log.e("Loki", "Failed to clear data", e)

View File

@ -25,8 +25,10 @@ class Address private constructor(address: String) : Parcelable, Comparable<Addr
get() = GroupUtil.isClosedGroup(address) get() = GroupUtil.isClosedGroup(address)
val isOpenGroup: Boolean val isOpenGroup: Boolean
get() = GroupUtil.isOpenGroup(address) get() = GroupUtil.isOpenGroup(address)
val isOpenGroupInbox: Boolean
get() = GroupUtil.isOpenGroupInbox(address)
val isContact: Boolean val isContact: Boolean
get() = !isGroup get() = !(isGroup || isOpenGroupInbox)
fun contactIdentifier(): String { fun contactIdentifier(): String {
if (!isContact && !isOpenGroup) { if (!isContact && !isOpenGroup) {

View File

@ -1,9 +1,8 @@
package org.session.libsession.utilities package org.session.libsession.utilities
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.file_server.FileServerAPIV2 import org.session.libsession.messaging.file_server.FileServerApi
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.messages.SignalServiceAttachment
import java.io.* import java.io.*
object DownloadUtilities { object DownloadUtilities {
@ -37,7 +36,7 @@ object DownloadUtilities {
val url = HttpUrl.parse(urlAsString)!! val url = HttpUrl.parse(urlAsString)!!
val fileID = url.pathSegments().last() val fileID = url.pathSegments().last()
try { try {
FileServerAPIV2.download(fileID.toLong()).get().let { FileServerApi.download(fileID).get().let {
outputStream.write(it) outputStream.write(it)
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -8,12 +8,18 @@ import kotlin.jvm.Throws
object GroupUtil { object GroupUtil {
const val CLOSED_GROUP_PREFIX = "__textsecure_group__!" const val CLOSED_GROUP_PREFIX = "__textsecure_group__!"
const val OPEN_GROUP_PREFIX = "__loki_public_chat_group__!" const val OPEN_GROUP_PREFIX = "__loki_public_chat_group__!"
const val OPEN_GROUP_INBOX_PREFIX = "__open_group_inbox__!"
@JvmStatic @JvmStatic
fun getEncodedOpenGroupID(groupID: ByteArray): String { fun getEncodedOpenGroupID(groupID: ByteArray): String {
return OPEN_GROUP_PREFIX + Hex.toStringCondensed(groupID) return OPEN_GROUP_PREFIX + Hex.toStringCondensed(groupID)
} }
@JvmStatic
fun getEncodedOpenGroupInboxID(groupInboxID: ByteArray): String {
return OPEN_GROUP_INBOX_PREFIX + Hex.toStringCondensed(groupInboxID)
}
@JvmStatic @JvmStatic
fun getEncodedClosedGroupID(groupID: ByteArray): String { fun getEncodedClosedGroupID(groupID: ByteArray): String {
return CLOSED_GROUP_PREFIX + Hex.toStringCondensed(groupID) return CLOSED_GROUP_PREFIX + Hex.toStringCondensed(groupID)
@ -45,6 +51,15 @@ object GroupUtil {
return Hex.fromStringCondensed(splitEncodedGroupID(groupID)) return Hex.fromStringCondensed(splitEncodedGroupID(groupID))
} }
@JvmStatic
fun getDecodedOpenGroupInbox(groupID: String): String {
val decodedGroupId = getDecodedGroupID(groupID)
if (decodedGroupId.split("!").count() > 2) {
return decodedGroupId.split("!", limit = 3)[2]
}
return decodedGroupId
}
fun isEncodedGroup(groupId: String): Boolean { fun isEncodedGroup(groupId: String): Boolean {
return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(OPEN_GROUP_PREFIX) return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(OPEN_GROUP_PREFIX)
} }
@ -54,6 +69,11 @@ object GroupUtil {
return groupId.startsWith(OPEN_GROUP_PREFIX) return groupId.startsWith(OPEN_GROUP_PREFIX)
} }
@JvmStatic
fun isOpenGroupInbox(groupId: String): Boolean {
return groupId.startsWith(OPEN_GROUP_INBOX_PREFIX)
}
@JvmStatic @JvmStatic
fun isClosedGroup(groupId: String): Boolean { fun isClosedGroup(groupId: String): Boolean {
return groupId.startsWith(CLOSED_GROUP_PREFIX) return groupId.startsWith(CLOSED_GROUP_PREFIX)

View File

@ -4,7 +4,7 @@ import android.content.Context
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import okio.Buffer 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.streams.ProfileCipherOutputStream
import org.session.libsignal.utilities.ProfileAvatarData import org.session.libsignal.utilities.ProfileAvatarData
import org.session.libsignal.streams.DigestingRequestBody import org.session.libsignal.streams.DigestingRequestBody
@ -30,13 +30,13 @@ object ProfilePictureUtilities {
var id: Long = 0 var id: Long = 0
try { try {
id = retryIfNeeded(4) { id = retryIfNeeded(4) {
FileServerAPIV2.upload(data) FileServerApi.upload(data)
}.get() }.get()
} catch (e: Exception) { } catch (e: Exception) {
deferred.reject(e) deferred.reject(e)
} }
TextSecurePreferences.setLastProfilePictureUpload(context, Date().time) TextSecurePreferences.setLastProfilePictureUpload(context, Date().time)
val url = "${FileServerAPIV2.server}/files/$id" val url = "${FileServerApi.server}/file/$id"
TextSecurePreferences.setProfilePictureURL(context, url) TextSecurePreferences.setProfilePictureURL(context, url)
deferred.resolve(Unit) deferred.resolve(Unit)
} }

View File

@ -40,6 +40,7 @@ import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.FutureTaskListener; import org.session.libsession.utilities.FutureTaskListener;
import org.session.libsession.utilities.GroupRecord; import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.GroupUtil;
import org.session.libsession.utilities.ListenableFutureTask; import org.session.libsession.utilities.ListenableFutureTask;
import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.MaterialColor;
import org.session.libsession.utilities.ProfilePictureModifiedEvent; import org.session.libsession.utilities.ProfilePictureModifiedEvent;
@ -314,6 +315,11 @@ public class Recipient implements RecipientModifiedListener {
} else { } else {
return this.name; 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 { } else {
Contact contact = storage.getContactWithSessionID(sessionID); Contact contact = storage.getContactWithSessionID(sessionID);
if (contact == null) { return sessionID; } if (contact == null) { return sessionID; }
@ -431,6 +437,10 @@ public class Recipient implements RecipientModifiedListener {
return address.isOpenGroup(); return address.isOpenGroup();
} }
public boolean isOpenGroupInboxRecipient() {
return address.isOpenGroupInbox();
}
public boolean isClosedGroupRecipient() { public boolean isClosedGroupRecipient() {
return address.isClosedGroup(); return address.isClosedGroup();
} }

View File

@ -18,7 +18,7 @@ dependencies {
implementation "androidx.annotation:annotation:1.2.0" implementation "androidx.annotation:annotation:1.2.0"
implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" 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 "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"

View File

@ -74,26 +74,26 @@ object HTTP {
/** /**
* Sync. Don't call from the main thread. * 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) return execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection)
} }
/** /**
* Sync. Don't call from the main thread. * Sync. Don't call from the main thread.
*/ */
fun execute(verb: Verb, url: String, parameters: Map<String, Any>?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): Map<*, *> { fun execute(verb: Verb, url: String, parameters: Map<String, Any>?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray {
if (parameters != null) { return if (parameters != null) {
val body = JsonUtil.toJson(parameters).toByteArray() 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 { } 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. * 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) val request = Request.Builder().url(url)
.removeHeader("User-Agent").addHeader("User-Agent", "WhatsApp") // Set a fake value .removeHeader("User-Agent").addHeader("User-Agent", "WhatsApp") // Set a fake value
.removeHeader("Accept-Language").addHeader("Accept-Language", "en-us") // 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 lateinit var response: Response
try { try {
val connection: OkHttpClient val connection = if (timeout != HTTP.timeout) { // Custom timeout
if (timeout != HTTP.timeout) { // Custom timeout
if (useSeedNodeConnection) { if (useSeedNodeConnection) {
throw IllegalStateException("Setting a custom timeout is only allowed for requests to snodes.") throw IllegalStateException("Setting a custom timeout is only allowed for requests to snodes.")
} }
connection = getDefaultConnection(timeout) getDefaultConnection(timeout)
} else { } else {
connection = if (useSeedNodeConnection) seedNodeConnection else defaultConnection if (useSeedNodeConnection) seedNodeConnection else defaultConnection
} }
response = connection.newCall(request.build()).execute() response = connection.newCall(request.build()).execute()
} catch (exception: Exception) { } catch (exception: Exception) {
@ -124,14 +123,9 @@ object HTTP {
// Override the actual error so that we can correctly catch failed requests in OnionRequestAPI // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI
throw HTTPRequestFailedException(0, null) throw HTTPRequestFailedException(0, null)
} }
when (val statusCode = response.code()) { return when (val statusCode = response.code()) {
200 -> { 200 -> {
val bodyAsString = response.body()?.string() ?: throw Exception("An error occurred.") response.body()?.bytes() ?: throw Exception("An error occurred.")
try {
return JsonUtil.fromJson(bodyAsString, Map::class.java)
} catch (exception: Exception) {
return mapOf( "result" to bodyAsString)
}
} }
else -> { else -> {
Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.") Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.")

View File

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

View File

@ -1,6 +1,7 @@
package org.session.libsignal.utilities; package org.session.libsignal.utilities;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@ -30,6 +31,10 @@ public class JsonUtil {
return fromJson(new String(serialized), clazz); return fromJson(new String(serialized), clazz);
} }
public static <T> T fromJson(String serialized, TypeReference<T> typeReference) throws IOException {
return objectMapper.readValue(serialized, typeReference);
}
public static <T> T fromJson(String serialized, Class<T> clazz) throws IOException { public static <T> T fromJson(String serialized, Class<T> clazz) throws IOException {
return objectMapper.readValue(serialized, clazz); return objectMapper.readValue(serialized, clazz);
} }

View File

@ -1,12 +1,10 @@
package org.session.libsignal.utilities package org.session.libsignal.utilities
import org.session.libsignal.utilities.Hex fun String.removingIdPrefixIfNeeded(): String {
return if (length == 66 && IdPrefix.fromValue(this) != null) removeRange(0..1) else this
fun String.removing05PrefixIfNeeded(): String {
return if (length == 66) removePrefix("05") else this
} }
fun ByteArray.removing05PrefixIfNeeded(): ByteArray { fun ByteArray.removingIdPrefixIfNeeded(): ByteArray {
val string = Hex.toStringCondensed(this).removing05PrefixIfNeeded() val string = Hex.toStringCondensed(this).removingIdPrefixIfNeeded()
return Hex.fromStringCondensed(string) return Hex.fromStringCondensed(string)
} }

View File

@ -10,9 +10,9 @@ object PublicKeyValidation {
@JvmStatic @JvmStatic
fun isValid(candidate: String, expectedLength: Int, isPrefixRequired: Boolean): Boolean { fun isValid(candidate: String, expectedLength: Int, isPrefixRequired: Boolean): Boolean {
val hexCharacters = "0123456789ABCDEF".toSet() val hexCharacters = "0123456789ABCDEF".toSet()
val isValidHexEncoding = hexCharacters.containsAll(candidate.toUpperCase().toSet()) val isValidHexEncoding = hexCharacters.containsAll(candidate.uppercase().toSet())
val hasValidLength = candidate.length == expectedLength 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 return isValidHexEncoding && hasValidLength && hasValidPrefix
} }
} }

View File

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