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>
pull/940/head
parent
b1e954084c
commit
bee287bb7e
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
configurations.maybeCreate("default")
|
||||
artifacts.add("default", file('lazysodium.aar'))
|
Binary file not shown.
@ -0,0 +1,8 @@
|
||||
package org.session.libsession.messaging
|
||||
|
||||
data class BlindedIdMapping(
|
||||
val blindedId: String,
|
||||
val sessionId: String?,
|
||||
val serverUrl: String,
|
||||
val serverId: String
|
||||
)
|
@ -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")
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,12 +1,10 @@
|
||||
package org.session.libsignal.utilities
|
||||
|
||||
import org.session.libsignal.utilities.Hex
|
||||
|
||||
fun String.removing05PrefixIfNeeded(): String {
|
||||
return if (length == 66) removePrefix("05") else this
|
||||
fun String.removingIdPrefixIfNeeded(): String {
|
||||
return if (length == 66 && IdPrefix.fromValue(this) != null) removeRange(0..1) else this
|
||||
}
|
||||
|
||||
fun ByteArray.removing05PrefixIfNeeded(): ByteArray {
|
||||
val string = Hex.toStringCondensed(this).removing05PrefixIfNeeded()
|
||||
fun ByteArray.removingIdPrefixIfNeeded(): ByteArray {
|
||||
val string = Hex.toStringCondensed(this).removingIdPrefixIfNeeded()
|
||||
return Hex.fromStringCondensed(string)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
rootProject.name = "session-android"
|
||||
|
||||
include ':app'
|
||||
include ':liblazysodium'
|
||||
include ':libsession'
|
||||
include ':libsignal'
|
Loading…
Reference in New Issue