Add Session Id blinding (#862)

* feat: Add Session Id blinding

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

* Add v4 onion request handling

* Update SOGS signature construction

* Fix SOGS signature construction

* Update onion request

* Update signature data

* Keep path prefixes for v4 endpoints

* Update SOGS signature message

* Rename to remove api version suffix

* Update onion response parsing

* Refactor file download paths

* Implement request batching

* Refactor batch response handling

* Handle batch endpoint responses

* Update batch endpoint responses

* Update attachment download handling

* Handle file downloads

* Handle inbox messages

* Fix issue with file downloads

* Preserve image bytearray encoding

* Refactor

* Open group message requests

* Check id blinding in user detail bottom sheet rather

* Message validation refactor

* Cache last inbox/outbox server ids

* Update message encryption/decryption

* Refactor

* Refactor

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

* Fix capabilities call auth

* Refactor

* Revert default server details

* Update sodium dependency to forked repo

* Fix attachment upload

* Revert "Update sodium dependency to forked repo"

This reverts commit c7db9529f9.

* Add signed sodium lib

* Update contact id truncation and mention logic

* Open group inbox messaging fix

* Refactor

* Update blinded id check

* Fix open group message sends

* Fix crash on open group direct message send

* Direct message refactor

* Direct message encrypt/decrypt fixes

* Use updated curve25519 version

* Updated lazysodium dependency

* Update encryption/decryption calls

* Handle direct message parse errors

* Minor refactor

* Existing chat refactor

* Update encryption & decryption parameters

* Fix authenticated ciphertext size

* Set direct message sync target

* Update direct message thread lookup

* Add blinded id mapping table

* Add blinded id mapping table

* Update threads after sends

* Update open group message timestamp handling

* Filter unblinded contacts

* Format blinded id mentions

* Add message deleted field

* Hide open group inbox id

* Update message request response handling

* Update message request response sender handling

* Fix mentions of blinded ids

* Handle open group poll failure

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

* fix: change the error check

* Persist group members

* Reschedule polling after capabilities update

* Retry on other exceptions

* Minor refactor

* Open group profile fix

* Group member db schema update

* Fix ban request key

* Update ban response type

* Ban endpoint updates

* Ban endpoint updates

* Delete messages

Co-authored-by: charles <charles@oxen.io>
Co-authored-by: jubb <hjubb@users.noreply.github.com>
This commit is contained in:
ceokot
2022-08-10 18:17:48 +10:00
committed by GitHub
parent b1e954084c
commit bee287bb7e
90 changed files with 3192 additions and 1190 deletions

View File

@@ -18,7 +18,8 @@ android {
dependencies {
implementation project(":libsignal")
implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation project(":liblazysodium")
// implementation 'com.goterl:lazysodium-android:5.0.2@aar'
implementation "net.java.dev.jna:jna:5.8.0@aar"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation 'androidx.core:core-ktx:1.3.2'
@@ -36,7 +37,7 @@ dependencies {
implementation 'com.esotericsoftware:kryo:5.1.1'
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "org.whispersystems:curve25519-java:$curve25519Version"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"

View File

@@ -11,12 +11,14 @@ import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupV2
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
@@ -54,13 +56,20 @@ interface StorageProtocol {
fun setAuthToken(room: String, server: String, newValue: String)
fun removeAuthToken(room: String, server: String)
// Servers
fun setServerCapabilities(server: String, capabilities: List<String>)
fun getServerCapabilities(server: String): List<String>
// Open Groups
fun getAllV2OpenGroups(): Map<Long, OpenGroupV2>
fun getV2OpenGroup(threadId: Long): OpenGroupV2?
fun getAllOpenGroups(): Map<Long, OpenGroup>
fun updateOpenGroup(openGroup: OpenGroup)
fun getOpenGroup(threadId: Long): OpenGroup?
fun addOpenGroup(urlAsString: String)
fun onOpenGroupAdded(urlAsString: String)
fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
fun getOpenGroup(room: String, server: String): OpenGroup?
fun addGroupMember(member: GroupMember)
// Open Group Public Keys
fun getOpenGroupPublicKey(server: String): String?
@@ -167,4 +176,16 @@ interface StorageProtocol {
fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean)
fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long)
fun conversationHasOutgoing(userPublicKey: String): Boolean
// Last Inbox Message Id
fun getLastInboxMessageId(server: String): Long?
fun setLastInboxMessageId(server: String, messageId: Long)
fun removeLastInboxMessageId(server: String)
// Last Outbox Message Id
fun getLastOutboxMessageId(server: String): Long?
fun setLastOutboxMessageId(server: String, messageId: Long)
fun removeLastOutboxMessageId(server: String)
fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping
}

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
@@ -106,15 +106,15 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
}
messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachment.attachmentId, this.databaseMessageID)
tempFile = createTempFile()
val openGroupV2 = storage.getV2OpenGroup(threadID)
if (openGroupV2 == null) {
val openGroup = storage.getOpenGroup(threadID)
if (openGroup == null) {
Log.d("AttachmentDownloadJob", "downloading normal attachment")
DownloadUtilities.downloadFile(tempFile, attachment.url)
} else {
Log.d("AttachmentDownloadJob", "downloading open group attachment")
val url = HttpUrl.parse(attachment.url)!!
val fileID = url.pathSegments().last()
OpenGroupAPIV2.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let {
OpenGroupApi.download(fileID, openGroup.room, openGroup.server).get().let {
tempFile.writeBytes(it)
}
}

View File

@@ -6,9 +6,10 @@ import com.esotericsoftware.kryo.io.Output
import nl.komponents.kovenant.Promise
import okio.Buffer
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.messaging.file_server.FileServerApi
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.DecodedAudio
@@ -50,15 +51,15 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID)
?: return handleFailure(Error.NoAttachment)
val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong())
if (v2OpenGroup != null) {
val keyAndResult = upload(attachment, v2OpenGroup.server, false) {
OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server)
val openGroup = storage.getOpenGroup(threadID.toLong())
if (openGroup != null) {
val keyAndResult = upload(attachment, openGroup.server, false) {
OpenGroupApi.upload(it, openGroup.room, openGroup.server)
}
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
} else {
val keyAndResult = upload(attachment, FileServerAPIV2.server, true) {
FileServerAPIV2.upload(it)
val keyAndResult = upload(attachment, FileServerApi.server, true) {
FileServerApi.upload(it)
}
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
}
@@ -100,7 +101,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val id = upload(data).get()
val digest = drb.transmittedDigest
// Return
return Pair(key, UploadResult(id, "${server}/files/$id", digest))
return Pair(key, UploadResult(id, "${server}/file/$id", digest))
}
private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) {
@@ -122,7 +123,25 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
Log.e("Loki", "Couldn't process audio attachment", e)
}
}
MessagingModuleConfiguration.shared.storage.resumeMessageSendJobIfNeeded(messageSendJobID)
val storage = MessagingModuleConfiguration.shared.storage
storage.getMessageSendJob(messageSendJobID)?.let {
val destination = it.destination as? Destination.OpenGroup ?: return@let
val updatedJob = MessageSendJob(
message = it.message,
destination = Destination.OpenGroup(
destination.roomToken,
destination.server,
destination.whisperTo,
destination.whisperMods,
destination.fileIds + uploadResult.id.toString()
)
)
updatedJob.id = it.id
updatedJob.delegate = it.delegate
updatedJob.failureCount = it.failureCount
storage.persistJob(updatedJob)
}
storage.resumeMessageSendJobIfNeeded(messageSendJobID)
}
private fun handlePermanentFailure(e: Exception) {

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package org.session.libsession.messaging.jobs
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.MessageReceiver
import org.session.libsession.messaging.sending_receiving.handle
import org.session.libsession.messaging.utilities.Data
@@ -32,7 +33,10 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val
val deferred = deferred<Unit, Exception>()
try {
val isRetry: Boolean = failureCount != 0
val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID)
val serverPublicKey = openGroupID?.let {
MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString("."))
}
val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey)
message.serverHash = serverHash
MessageReceiver.handle(message, proto, this.openGroupID)
this.handleSuccess()

View File

@@ -3,8 +3,6 @@ package org.session.libsession.messaging.jobs
import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output
import nl.komponents.kovenant.FailedException
import nl.komponents.kovenant.Promise
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE
import org.session.libsession.messaging.messages.Destination

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
package org.session.libsession.messaging.open_groups
sealed class Endpoint(val value: String) {
object Onion : Endpoint("oxen/v4/lsrpc")
object Batch : Endpoint("batch")
object Sequence : Endpoint("sequence")
object Capabilities : Endpoint("capabilities")
// Rooms
object Rooms : Endpoint("rooms")
data class Room(val roomToken: String) : Endpoint("room/$roomToken")
data class RoomPollInfo(val roomToken: String, val infoUpdated: Int) :
Endpoint("room/$roomToken/pollInfo/$infoUpdated")
// Messages
data class RoomMessage(val roomToken: String) : Endpoint("room/$roomToken/message")
data class RoomMessageIndividual(val roomToken: String, val messageId: Long) :
Endpoint("room/$roomToken/message/$messageId")
data class RoomMessagesRecent(val roomToken: String) :
Endpoint("room/$roomToken/messages/recent")
data class RoomMessagesBefore(val roomToken: String, val messageId: Long) :
Endpoint("room/$roomToken/messages/before/$messageId")
data class RoomMessagesSince(val roomToken: String, val seqNo: Long) :
Endpoint("room/$roomToken/messages/since/$seqNo")
data class RoomDeleteMessages(val roomToken: String, val sessionId: String) :
Endpoint("room/$roomToken/all/$sessionId")
// Pinning
data class RoomPinMessage(val roomToken: String, val messageId: Long) :
Endpoint("room/$roomToken/pin/$messageId")
data class RoomUnpinMessage(val roomToken: String, val messageId: Long) :
Endpoint("room/$roomToken/unpin/$messageId")
data class RoomUnpinAll(val roomToken: String) : Endpoint("room/$roomToken/unpin/all")
// Files
object File: Endpoint("file")
data class FileIndividual(val fileId: Long): Endpoint("file/$fileId")
data class RoomFile(val roomToken: String) : Endpoint("room/$roomToken/file")
data class RoomFileIndividual(
val roomToken: String,
val fileId: String
) : Endpoint("room/$roomToken/file/$fileId")
// Inbox/Outbox (Message Requests)
object Inbox : Endpoint("inbox")
data class InboxSince(val id: Long) : Endpoint("inbox/since/$id")
data class InboxFor(val sessionId: String) : Endpoint("inbox/$sessionId")
object Outbox : Endpoint("outbox")
data class OutboxSince(val id: Long) : Endpoint("outbox/since/$id")
// Users
data class UserBan(val sessionId: String) : Endpoint("user/$sessionId/ban")
data class UserUnban(val sessionId: String) : Endpoint("user/$sessionId/unban")
data class UserModerator(val sessionId: String) : Endpoint("user/$sessionId/moderator")
}

View File

@@ -0,0 +1,11 @@
package org.session.libsession.messaging.open_groups
data class GroupMember(
val groupId: String,
val profileId: String,
val role: GroupMemberRole
)
enum class GroupMemberRole {
STANDARD, ZOOMBIE, MODERATOR, ADMIN
}

View File

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

View File

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

View File

@@ -0,0 +1,830 @@
package org.session.libsession.messaging.open_groups
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming
import com.fasterxml.jackson.databind.type.TypeFactory
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.Sign
import kotlinx.coroutines.flow.MutableSharedFlow
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.OnionResponse
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Base64.decode
import org.session.libsignal.utilities.Base64.encodeBytes
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.HTTP.Verb.DELETE
import org.session.libsignal.utilities.HTTP.Verb.GET
import org.session.libsignal.utilities.HTTP.Verb.POST
import org.session.libsignal.utilities.HTTP.Verb.PUT
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.whispersystems.curve25519.Curve25519
import java.util.concurrent.TimeUnit
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
object OpenGroupApi {
private val curve = Curve25519.getInstance(Curve25519.BEST)
val defaultRooms = MutableSharedFlow<List<DefaultGroup>>(replay = 1)
private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>()
private var hasUpdatedLastOpenDate = false
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
private val timeSinceLastOpen by lazy {
val context = MessagingModuleConfiguration.shared.context
val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context)
val now = System.currentTimeMillis()
now - lastOpenDate
}
const val defaultServerPublicKey =
"a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val defaultServer = "http://116.203.70.33"
sealed class Error(message: String) : Exception(message) {
object Generic : Error("An error occurred.")
object ParsingFailed : Error("Invalid response.")
object DecryptionFailed : Error("Couldn't decrypt response.")
object SigningFailed : Error("Couldn't sign message.")
object InvalidURL : Error("Invalid URL.")
object NoPublicKey : Error("Couldn't find server public key.")
object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.")
}
data class DefaultGroup(val id: String, val name: String, val image: ByteArray?) {
val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey"
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class RoomInfo(
val token: String = "",
val name: String = "",
val description: String = "",
val infoUpdates: Int = 0,
val messageSequence: Long = 0,
val created: Long = 0,
val activeUsers: Int = 0,
val activeUsersCutoff: Int = 0,
val imageId: Long? = null,
val pinnedMessages: List<PinnedMessage> = emptyList(),
val admin: Boolean = false,
val globalAdmin: Boolean = false,
val admins: List<String> = emptyList(),
val hiddenAdmins: List<String> = emptyList(),
val moderator: Boolean = false,
val globalModerator: Boolean = false,
val moderators: List<String> = emptyList(),
val hiddenModerators: List<String> = emptyList(),
val read: Boolean = false,
val defaultRead: Boolean = false,
val defaultAccessible: Boolean = false,
val write: Boolean = false,
val defaultWrite: Boolean = false,
val upload: Boolean = false,
val defaultUpload: Boolean = false,
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class PinnedMessage(
val id: Long = 0,
val pinnedAt: Long = 0,
val pinnedBy: String = ""
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class BatchRequestInfo<T>(
val request: BatchRequest,
val endpoint: Endpoint,
val responseType: TypeReference<T>
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class BatchRequest(
val method: HTTP.Verb,
val path: String,
val headers: Map<String, String> = emptyMap(),
val json: Map<String, Any>? = null,
val b64: String? = null,
val bytes: ByteArray? = null,
)
data class BatchResponse<T>(
val endpoint: Endpoint,
val code: Int,
val headers: Map<String, String>,
val body: T?
)
data class Capabilities(
val capabilities: List<String> = emptyList(),
val missing: List<String> = emptyList()
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class RoomPollInfo(
val token: String = "",
val activeUsers: Int = 0,
val admin: Boolean = false,
val globalAdmin: Boolean = false,
val moderator: Boolean = false,
val globalModerator: Boolean = false,
val read: Boolean = false,
val defaultRead: Boolean = false,
val defaultAccessible: Boolean = false,
val write: Boolean = false,
val defaultWrite: Boolean = false,
val upload: Boolean = false,
val defaultUpload: Boolean = false,
val details: RoomInfo? = null
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class DirectMessage(
val id: Long = 0,
val sender: String = "",
val recipient: String = "",
val postedAt: Long = 0,
val expiresAt: Long = 0,
val message: String = "",
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class Message(
val id : Long = 0,
val sessionId: String = "",
val posted: Double = 0.0,
val edited: Long = 0,
val seqno: Long = 0,
val deleted: Boolean = false,
val whisper: Boolean = false,
val whisperMods: String = "",
val whisperTo: String = "",
val data: String? = null,
val signature: String? = null
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class SendMessageRequest(
val data: String? = null,
val signature: String? = null,
val whisperTo: List<String>? = null,
val whisperMods: Boolean? = null,
val files: List<String>? = null
)
data class MessageDeletion(
@JsonProperty("id")
val id: Long = 0,
@JsonProperty("deleted_message_id")
val deletedMessageServerID: Long = 0
) {
companion object {
val empty = MessageDeletion()
}
}
data class Request(
val verb: HTTP.Verb,
val room: String?,
val server: String,
val endpoint: Endpoint,
val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
/**
* Always `true` under normal circumstances. You might want to disable
* this when running over Lokinet.
*/
val useOnionRouting: Boolean = true
)
private fun createBody(parameters: Any?): RequestBody? {
if (parameters == null) return null
val parametersAsJSON = JsonUtil.toJson(parameters)
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
}
private fun getResponseBody(request: Request): Promise<ByteArray, Exception> {
return send(request).map { response ->
response.body ?: throw Error.ParsingFailed
}
}
private fun getResponseBodyJson(request: Request): Promise<Map<*, *>, Exception> {
return send(request).map {
JsonUtil.fromJson(it.body, Map::class.java)
}
}
private fun send(request: Request): Promise<OnionResponse, Exception> {
val url = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
.host(url.host())
.port(url.port())
.addPathSegments(request.endpoint.value)
if (request.verb == GET) {
for ((key, value) in request.queryParameters) {
urlBuilder.addQueryParameter(key, value)
}
}
fun execute(): Promise<OnionResponse, Exception> {
val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(request.server)
val publicKey =
MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server)
?: return Promise.ofFail(Error.NoPublicKey)
val ed25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
?: return Promise.ofFail(Error.NoEd25519KeyPair)
val urlRequest = urlBuilder.build()
val headers = request.headers.toMutableMap()
if (request.isAuthRequired) {
val nonce = sodium.nonce(16)
val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
var pubKey = ""
var signature = ByteArray(Sign.BYTES)
var bodyHash = ByteArray(0)
if (request.parameters != null) {
val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray()
val parameterHash = ByteArray(GenericHash.BYTES_MAX)
if (sodium.cryptoGenericHash(
parameterHash,
parameterHash.size,
parameterBytes,
parameterBytes.size.toLong()
)
) {
bodyHash = parameterHash
}
}
val messageBytes = Hex.fromStringCondensed(publicKey)
.plus(nonce)
.plus("$timestamp".toByteArray(Charsets.US_ASCII))
.plus(request.verb.rawValue.toByteArray())
.plus(urlRequest.encodedPath().toByteArray())
.plus(bodyHash)
if (serverCapabilities.contains("blind")) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SessionId(
IdPrefix.BLINDED,
keyPair.publicKey.asBytes
).hexString
signature = SodiumUtilities.sogsSignature(
messageBytes,
ed25519KeyPair.secretKey.asBytes,
keyPair.secretKey.asBytes,
keyPair.publicKey.asBytes
) ?: return Promise.ofFail(Error.SigningFailed)
} ?: return Promise.ofFail(Error.SigningFailed)
} else {
pubKey = SessionId(
IdPrefix.UN_BLINDED,
ed25519KeyPair.publicKey.asBytes
).hexString
sodium.cryptoSignDetached(
signature,
messageBytes,
messageBytes.size.toLong(),
ed25519KeyPair.secretKey.asBytes
)
}
headers["X-SOGS-Nonce"] = encodeBytes(nonce)
headers["X-SOGS-Timestamp"] = "$timestamp"
headers["X-SOGS-Pubkey"] = pubKey
headers["X-SOGS-Signature"] = encodeBytes(signature)
}
val requestBuilder = okhttp3.Request.Builder()
.url(urlRequest)
.headers(Headers.of(headers))
when (request.verb) {
GET -> requestBuilder.get()
PUT -> requestBuilder.put(createBody(request.parameters)!!)
POST -> requestBuilder.post(createBody(request.parameters)!!)
DELETE -> requestBuilder.delete(createBody(request.parameters))
}
if (!request.room.isNullOrEmpty()) {
requestBuilder.header("Room", request.room)
}
return if (request.useOnionRouting) {
OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e ->
Log.e("SOGS", "Failed onion request", e)
}
} else {
Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
}
}
return execute()
}
fun downloadOpenGroupProfilePicture(
server: String,
roomID: String,
imageId: Long
): Promise<ByteArray, Exception> {
val request = Request(
verb = GET,
room = roomID,
server = server,
endpoint = Endpoint.RoomFileIndividual(roomID, imageId.toString())
)
return getResponseBody(request)
}
// region Upload/Download
fun upload(file: ByteArray, room: String, server: String): Promise<Long, Exception> {
val parameters = mapOf("file" to file)
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = Endpoint.RoomFile(room),
parameters = parameters
)
return getResponseBodyJson(request).map { json ->
(json["id"] as? Number)?.toLong() ?: throw Error.ParsingFailed
}
}
fun download(fileId: String, room: String, server: String): Promise<ByteArray, Exception> {
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = Endpoint.RoomFileIndividual(room, fileId)
)
return getResponseBody(request)
}
// endregion
// region Sending
fun sendMessage(
message: OpenGroupMessage,
room: String,
server: String,
whisperTo: List<String>? = null,
whisperMods: Boolean? = null,
fileIds: List<String>? = null
): Promise<OpenGroupMessage, Exception> {
val signedMessage = message.sign(room, server, fallbackSigningType = IdPrefix.STANDARD) ?: return Promise.ofFail(Error.SigningFailed)
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = Endpoint.RoomMessage(room),
parameters = signedMessage.toJSON()
)
return getResponseBodyJson(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessage = json as? Map<String, Any>
?: throw Error.ParsingFailed
val result = OpenGroupMessage.fromJSON(rawMessage) ?: throw Error.ParsingFailed
val storage = MessagingModuleConfiguration.shared.storage
storage.addReceivedMessageTimestamp(result.sentTimestamp)
result
}
}
// endregion
// region Messages
fun getMessages(room: String, server: String): Promise<List<OpenGroupMessage>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastMessageServerID(room, server)?.let { lastId ->
queryParameters += "from_server_id" to lastId.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = Endpoint.RoomMessage(room),
queryParameters = queryParameters
)
return getResponseBodyJson(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessages =
json["messages"] as? List<Map<String, Any>>
?: throw Error.ParsingFailed
parseMessages(room, server, rawMessages)
}
}
private fun parseMessages(
room: String,
server: String,
rawMessages: List<Map<*, *>>
): List<OpenGroupMessage> {
val messages = rawMessages.mapNotNull { json ->
json as Map<String, Any>
try {
val message = OpenGroupMessage.fromJSON(json) ?: return@mapNotNull null
if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null
val sender = message.sender
val data = decode(message.base64EncodedData)
val signature = decode(message.base64EncodedSignature)
val publicKey = Hex.fromStringCondensed(sender.removingIdPrefixIfNeeded())
val isValid = curve.verifySignature(publicKey, data, signature)
if (!isValid) {
Log.d("Loki", "Ignoring message with invalid signature.")
return@mapNotNull null
}
message
} catch (e: Exception) {
null
}
}
return messages
}
// endregion
// region Message Deletion
@JvmStatic
fun deleteMessage(serverID: Long, room: String, server: String): Promise<Unit, Exception> {
val request =
Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID))
return send(request).map {
Log.d("Loki", "Message deletion successful.")
}
}
fun getDeletedMessages(
room: String,
server: String
): Promise<List<MessageDeletion>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastDeletionServerID(room, server)?.let { last ->
queryParameters["from_server_id"] = last.toString()
}
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = Endpoint.RoomDeleteMessages(room, storage.getUserPublicKey() ?: ""),
queryParameters = queryParameters
)
return getResponseBody(request).map { response ->
val json = JsonUtil.fromJson(response, Map::class.java)
val type = TypeFactory.defaultInstance()
.constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["ids"])
val serverIDs = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type)
?: throw Error.ParsingFailed
val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0
val serverID = serverIDs.maxByOrNull { it.id } ?: MessageDeletion.empty
if (serverID.id > lastMessageServerId) {
storage.setLastDeletionServerID(room, server, serverID.id)
}
serverIDs
}
}
// endregion
// region Moderation
@JvmStatic
fun ban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val parameters = mapOf("rooms" to listOf(room))
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = Endpoint.UserBan(publicKey),
parameters = parameters
)
return send(request).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
method = POST,
path = "/user/$publicKey/ban",
json = mapOf("rooms" to listOf(room))
),
endpoint = Endpoint.UserBan(publicKey),
responseType = object: TypeReference<Any>(){}
),
BatchRequestInfo(
request = BatchRequest(DELETE, "/room/$room/all/$publicKey"),
endpoint = Endpoint.RoomDeleteMessages(room, publicKey),
responseType = object: TypeReference<Any>(){}
)
)
return sequentialBatch(server, requests).map {
Log.d("Loki", "Banned user: $publicKey from: $server.$room.")
}
}
fun unban(publicKey: String, room: String, server: String): Promise<Unit, Exception> {
val request =
Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey))
return send(request).map {
Log.d("Loki", "Unbanned user: $publicKey from: $server.$room")
}
}
// endregion
// region General
@Suppress("UNCHECKED_CAST")
fun poll(
rooms: List<String>,
server: String
): Promise<List<BatchResponse<*>>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context
val timeSinceLastOpen = this.timeSinceLastOpen
val shouldRetrieveRecentMessages = (hasPerformedInitialPoll[server] != true
&& timeSinceLastOpen > maxInactivityPeriod)
hasPerformedInitialPoll[server] = true
if (!hasUpdatedLastOpenDate) {
hasUpdatedLastOpenDate = true
TextSecurePreferences.setLastOpenDate(context)
}
val lastInboxMessageId = storage.getLastInboxMessageId(server)
val lastOutboxMessageId = storage.getLastOutboxMessageId(server)
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/capabilities"
),
endpoint = Endpoint.Capabilities,
responseType = object : TypeReference<Capabilities>(){}
)
)
rooms.forEach { room ->
val infoUpdates = storage.getOpenGroup(room, server)?.infoUpdates ?: 0
requests.add(
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/pollInfo/$infoUpdates"
),
endpoint = Endpoint.RoomPollInfo(room, infoUpdates),
responseType = object : TypeReference<RoomPollInfo>(){}
)
)
requests.add(
if (shouldRetrieveRecentMessages) {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/messages/recent"
),
endpoint = Endpoint.RoomMessagesRecent(room),
responseType = object : TypeReference<List<Message>>(){}
)
} else {
val lastMessageServerId = storage.getLastMessageServerID(room, server) ?: 0L
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/messages/since/$lastMessageServerId"
),
endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId),
responseType = object : TypeReference<List<Message>>(){}
)
}
)
}
val serverCapabilities = storage.getServerCapabilities(server)
if (serverCapabilities.contains("blind")) {
requests.add(
if (lastInboxMessageId == null) {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/inbox"
),
endpoint = Endpoint.Inbox,
responseType = object : TypeReference<List<DirectMessage>>() {}
)
} else {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/inbox/since/$lastInboxMessageId"
),
endpoint = Endpoint.InboxSince(lastInboxMessageId),
responseType = object : TypeReference<List<DirectMessage>>() {}
)
}
)
requests.add(
if (lastOutboxMessageId == null) {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/outbox"
),
endpoint = Endpoint.Outbox,
responseType = object : TypeReference<List<DirectMessage>>() {}
)
} else {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/outbox/since/$lastOutboxMessageId"
),
endpoint = Endpoint.OutboxSince(lastOutboxMessageId),
responseType = object : TypeReference<List<DirectMessage>>() {}
)
}
)
}
return parallelBatch(server, requests)
}
private fun parallelBatch(
server: String,
requests: MutableList<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, Exception> {
val request = Request(
verb = POST,
room = null,
server = server,
endpoint = Endpoint.Batch,
parameters = requests.map { it.request }
)
return getBatchResponseJson(request, requests)
}
private fun sequentialBatch(
server: String,
requests: MutableList<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, Exception> {
val request = Request(
verb = POST,
room = null,
server = server,
endpoint = Endpoint.Sequence,
parameters = requests.map { it.request }
)
return getBatchResponseJson(request, requests)
}
private fun getBatchResponseJson(
request: Request,
requests: MutableList<BatchRequestInfo<*>>
): Promise<List<BatchResponse<*>>, Exception> {
return getResponseBody(request).map { batch ->
val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed
results.mapIndexed { idx, result ->
val response = result as? Map<*, *> ?: throw Error.ParsingFailed
val code = response["code"] as Int
BatchResponse(
endpoint = requests[idx].endpoint,
code = code,
headers = response["headers"] as Map<String, String>,
body = if (code in 200..299) {
JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let {
JsonUtil.fromJson(it, requests[idx].responseType)
}
} else null
)
}
}
}
fun getDefaultServerCapabilities(): Promise<Capabilities, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey)
return getCapabilities(defaultServer).map { capabilities ->
storage.setServerCapabilities(defaultServer, capabilities.capabilities)
capabilities
}
}
fun getDefaultRoomsIfNeeded(): Promise<List<DefaultGroup>, Exception> {
return getAllRooms().map { groups ->
val earlyGroups = groups.map { group ->
DefaultGroup(group.token, group.name, null)
}
// See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results
defaultRooms.replayCache.firstOrNull()?.let { replayed ->
if (replayed.none { it.image?.isNotEmpty() == true }) {
defaultRooms.tryEmit(earlyGroups)
}
}
val images = groups.associate { group ->
group.token to group.imageId?.let { downloadOpenGroupProfilePicture(defaultServer, group.token, it) }
}
groups.map { group ->
val image = try {
images[group.token]!!.get()
} catch (e: Exception) {
// No image or image failed to download
null
}
DefaultGroup(group.token, group.name, image)
}
}.success { new ->
defaultRooms.tryEmit(new)
}
}
fun getRoomInfo(roomToken: String, server: String): Promise<RoomInfo, Exception> {
val request = Request(
verb = GET,
room = null,
server = server,
endpoint = Endpoint.Room(roomToken)
)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, RoomInfo::class.java)
}
}
private fun getAllRooms(): Promise<List<RoomInfo>, Exception> {
val request = Request(
verb = GET,
room = null,
server = defaultServer,
endpoint = Endpoint.Rooms
)
return getResponseBody(request).map { response ->
val rawRooms = JsonUtil.fromJson(response, List::class.java) ?: throw Error.ParsingFailed
rawRooms.mapNotNull {
JsonUtil.fromJson(JsonUtil.toJson(it), RoomInfo::class.java)
}
}
}
fun getMemberCount(room: String, server: String): Promise<Int, Exception> {
return getRoomInfo(room, server).map { info ->
val storage = MessagingModuleConfiguration.shared.storage
storage.setUserCount(room, server, info.activeUsers)
info.activeUsers
}
}
fun getCapabilities(server: String): Promise<Capabilities, Exception> {
val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities, isAuthRequired = false)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, Capabilities::class.java)
}
}
fun getCapabilitiesAndRoomInfo(room: String, server: String): Promise<Pair<Capabilities, RoomInfo>, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/capabilities"
),
endpoint = Endpoint.Capabilities,
responseType = object : TypeReference<Capabilities>(){}
),
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room"
),
endpoint = Endpoint.Room(room),
responseType = object : TypeReference<RoomInfo>(){}
)
)
return sequentialBatch(server, requests).map {
val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed
val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed
capabilities to roomInfo
}
}
fun sendDirectMessage(message: String, blindedSessionId: String, server: String): Promise<DirectMessage, Exception> {
val request = Request(
verb = POST,
room = null,
server = server,
endpoint = Endpoint.InboxFor(blindedSessionId),
parameters = mapOf("message" to message)
)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, DirectMessage::class.java)
}
}
// endregion
}

View File

@@ -0,0 +1,93 @@
package org.session.libsession.messaging.open_groups
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsignal.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Base64.decode
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import org.whispersystems.curve25519.Curve25519
data class OpenGroupMessage(
val serverID: Long? = null,
val sender: String?,
val sentTimestamp: Long,
/**
* The serialized protobuf in base64 encoding.
*/
val base64EncodedData: String,
/**
* When sending a message, the sender signs the serialized protobuf with their private key so that
* a receiving user can verify that the message wasn't tampered with.
*/
val base64EncodedSignature: String? = null
) {
companion object {
private val curve = Curve25519.getInstance(Curve25519.BEST)
fun fromJSON(json: Map<String, Any>): OpenGroupMessage? {
val base64EncodedData = json["data"] as? String ?: return null
val sentTimestamp = json["posted"] as? Double ?: return null
val serverID = json["id"] as? Int
val sender = json["session_id"] as? String
val base64EncodedSignature = json["signature"] as? String
return OpenGroupMessage(
serverID = serverID?.toLong(),
sender = sender,
sentTimestamp = (sentTimestamp * 1000).toLong(),
base64EncodedData = base64EncodedData,
base64EncodedSignature = base64EncodedSignature
)
}
}
fun sign(room: String, server: String, fallbackSigningType: IdPrefix): OpenGroupMessage? {
if (base64EncodedData.isEmpty()) return null
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null
val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server)
val signature = when {
serverCapabilities.contains("blind") -> {
val blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.publicKey, userEdKeyPair) ?: return null
SodiumUtilities.sogsSignature(
decode(base64EncodedData),
userEdKeyPair.secretKey.asBytes,
blindedKeyPair.secretKey.asBytes,
blindedKeyPair.publicKey.asBytes
) ?: return null
}
fallbackSigningType == IdPrefix.UN_BLINDED -> {
curve.calculateSignature(userEdKeyPair.secretKey.asBytes, decode(base64EncodedData))
}
else -> {
val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() }
if (sender != publicKey.toHexString() && !userEdKeyPair.publicKey.asHexString.equals(sender?.removingIdPrefixIfNeeded(), true)) return null
try {
curve.calculateSignature(privateKey, decode(base64EncodedData))
} catch (e: Exception) {
Log.w("Loki", "Couldn't sign open group message.", e)
return null
}
}
}
return copy(base64EncodedSignature = Base64.encodeBytes(signature))
}
fun toJSON(): Map<String, Any> {
val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp )
serverID?.let { json["server_id"] = it }
sender?.let { json["public_key"] = it }
base64EncodedSignature?.let { json["signature"] = it }
return json
}
fun toProto(): SignalServiceProtos.Content {
val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody)
return SignalServiceProtos.Content.parseFrom(data)
}
}

View File

@@ -1,72 +0,0 @@
package org.session.libsession.messaging.open_groups
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsignal.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Base64.decode
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString
import org.whispersystems.curve25519.Curve25519
data class OpenGroupMessageV2(
val serverID: Long? = null,
val sender: String?,
val sentTimestamp: Long,
/**
* The serialized protobuf in base64 encoding.
*/
val base64EncodedData: String,
/**
* When sending a message, the sender signs the serialized protobuf with their private key so that
* a receiving user can verify that the message wasn't tampered with.
*/
val base64EncodedSignature: String? = null
) {
companion object {
private val curve = Curve25519.getInstance(Curve25519.BEST)
fun fromJSON(json: Map<String, Any>): OpenGroupMessageV2? {
val base64EncodedData = json["data"] as? String ?: return null
val sentTimestamp = json["timestamp"] as? Long ?: return null
val serverID = json["server_id"] as? Int
val sender = json["public_key"] as? String
val base64EncodedSignature = json["signature"] as? String
return OpenGroupMessageV2(
serverID = serverID?.toLong(),
sender = sender,
sentTimestamp = sentTimestamp,
base64EncodedData = base64EncodedData,
base64EncodedSignature = base64EncodedSignature
)
}
}
fun sign(): OpenGroupMessageV2? {
if (base64EncodedData.isEmpty()) return null
val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey to it.privateKey }
if (sender != publicKey.serialize().toHexString()) return null
val signature = try {
curve.calculateSignature(privateKey.serialize(), decode(base64EncodedData))
} catch (e: Exception) {
Log.w("Loki", "Couldn't sign open group message.", e)
return null
}
return copy(base64EncodedSignature = Base64.encodeBytes(signature))
}
fun toJSON(): Map<String, Any> {
val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp )
serverID?.let { json["server_id"] = it }
sender?.let { json["public_key"] = it }
base64EncodedSignature?.let { json["signature"] = it }
return json
}
fun toProto(): SignalServiceProtos.Content {
val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody)
return SignalServiceProtos.Content.parseFrom(data)
}
}

View File

@@ -5,11 +5,15 @@ import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.Box
import com.goterl.lazysodium.interfaces.Sign
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
object MessageDecrypter {
@@ -25,7 +29,7 @@ object MessageDecrypter {
*/
public fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair<ByteArray, String> {
val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize()
val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded())
val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded())
val signatureSize = Sign.BYTES
val ed25519PublicKeySize = Sign.PUBLICKEYBYTES
@@ -35,9 +39,9 @@ object MessageDecrypter {
sodium.cryptoBoxSealOpen(plaintextWithMetadata, ciphertext, ciphertext.size.toLong(), recipientX25519PublicKey, recipientX25519PrivateKey)
} catch (exception: Exception) {
Log.d("Loki", "Couldn't decrypt message due to error: $exception.")
throw MessageReceiver.Error.DecryptionFailed
throw Error.DecryptionFailed
}
if (plaintextWithMetadata.size <= (signatureSize + ed25519PublicKeySize)) { throw MessageReceiver.Error.DecryptionFailed }
if (plaintextWithMetadata.size <= (signatureSize + ed25519PublicKeySize)) { throw Error.DecryptionFailed }
// 2. ) Get the message parts
val signature = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - signatureSize until plaintextWithMetadata.size)
val senderED25519PublicKey = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize) until plaintextWithMetadata.size - signatureSize)
@@ -46,15 +50,62 @@ object MessageDecrypter {
val verificationData = (plaintext + senderED25519PublicKey + recipientX25519PublicKey)
try {
val isValid = sodium.cryptoSignVerifyDetached(signature, verificationData, verificationData.size, senderED25519PublicKey)
if (!isValid) { throw MessageReceiver.Error.InvalidSignature }
if (!isValid) { throw Error.InvalidSignature }
} catch (exception: Exception) {
Log.d("Loki", "Couldn't verify message signature due to error: $exception.")
throw MessageReceiver.Error.InvalidSignature
throw Error.InvalidSignature
}
// 4. ) Get the sender's X25519 public key
val senderX25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES)
sodium.convertPublicKeyEd25519ToCurve25519(senderX25519PublicKey, senderED25519PublicKey)
return Pair(plaintext, "05" + senderX25519PublicKey.toHexString())
val id = SessionId(IdPrefix.STANDARD, senderX25519PublicKey)
return Pair(plaintext, id.hexString)
}
fun decryptBlinded(
message: ByteArray,
isOutgoing: Boolean,
otherBlindedPublicKey: String,
serverPublicKey: String
): Pair<ByteArray, String> {
if (message.size < Box.NONCEBYTES + 2) throw Error.DecryptionFailed
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.DecryptionFailed
// Calculate the shared encryption key, receiving from A to B
val otherKeyBytes = Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded())
val kA = if (isOutgoing) blindedKeyPair.publicKey.asBytes else otherKeyBytes
val decryptionKey = SodiumUtilities.sharedBlindedEncryptionKey(
userEdKeyPair.secretKey.asBytes,
otherKeyBytes,
kA,
if (isOutgoing) otherKeyBytes else blindedKeyPair.publicKey.asBytes
) ?: throw Error.DecryptionFailed
// v, ct, nc = data[0], data[1:-24], data[-24:size]
val version = message.first().toInt()
if (version != 0) throw Error.DecryptionFailed
val ciphertext = message.drop(1).dropLast(Box.NONCEBYTES).toByteArray()
val nonce = message.takeLast(Box.NONCEBYTES).toByteArray()
// Decrypt the message
val innerBytes = SodiumUtilities.decrypt(ciphertext, decryptionKey, nonce) ?: throw Error.DecryptionFailed
if (innerBytes.size < Sign.PUBLICKEYBYTES) throw Error.DecryptionFailed
// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key
val plaintextEndIndex = innerBytes.size - Sign.PUBLICKEYBYTES
val plaintext = innerBytes.slice(0 until plaintextEndIndex).toByteArray()
val senderEdPublicKey = innerBytes.slice((plaintextEndIndex until innerBytes.size)).toByteArray()
// Verify that the inner senderEdPublicKey (A) yields the same outer kA we got with the message
val blindingFactor = SodiumUtilities.generateBlindingFactor(serverPublicKey) ?: throw Error.DecryptionFailed
val sharedSecret = SodiumUtilities.combineKeys(blindingFactor, senderEdPublicKey) ?: throw Error.DecryptionFailed
if (!kA.contentEquals(sharedSecret)) throw Error.InvalidSignature
// Get the sender's X25519 public key
val senderX25519PublicKey = SodiumUtilities.toX25519(senderEdPublicKey) ?: throw Error.InvalidSignature
val id = SessionId(IdPrefix.STANDARD, senderX25519PublicKey)
return Pair(plaintext, id.hexString)
}
}

View File

@@ -6,9 +6,11 @@ import com.goterl.lazysodium.interfaces.Box
import com.goterl.lazysodium.interfaces.Sign
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
object MessageEncrypter {
@@ -24,7 +26,7 @@ object MessageEncrypter {
*/
internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray {
val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded())
val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded())
val verificationData = plaintext + userED25519KeyPair.publicKey.asBytes + recipientX25519PublicKey
val signature = ByteArray(Sign.BYTES)
@@ -46,4 +48,33 @@ object MessageEncrypter {
return ciphertext
}
internal fun encryptBlinded(
plaintext: ByteArray,
recipientBlindedId: String,
serverPublicKey: String
): ByteArray {
if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed
val userEdKeyPair =
MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.SigningFailed
val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded())
// Calculate the shared encryption key, sending from A to B
val encryptionKey = SodiumUtilities.sharedBlindedEncryptionKey(
userEdKeyPair.secretKey.asBytes,
recipientBlindedPublicKey,
blindedKeyPair.publicKey.asBytes,
recipientBlindedPublicKey
) ?: throw Error.SigningFailed
// Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey)
val message = plaintext + userEdKeyPair.publicKey.asBytes
// Encrypt using xchacha20-poly1305
val nonce = sodium.nonce(24)
val ciphertext = SodiumUtilities.encrypt(message, encryptionKey, nonce) ?: throw Error.EncryptionFailed
// data = b'\x00' + ciphertext + nonce
return byteArrayOf(0.toByte()) + ciphertext + nonce
}
}

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
@@ -38,9 +40,10 @@ import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import java.security.MessageDigest
import java.util.LinkedList
@@ -153,7 +156,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer)
}
}
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL }
val allV2OpenGroups = storage.getAllOpenGroups().map { it.value.joinURL }
for (openGroup in message.openGroups) {
if (allV2OpenGroups.contains(openGroup)) continue
Log.d("OpenGroup", "All open groups doesn't contain $openGroup")
@@ -216,8 +219,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
// Get or create thread
// FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet
// exist. This is intentional, but it's very non-obvious.
val threadID = storage.getOrCreateThreadIdFor(message.syncTarget
?: messageSender!!, message.groupPublicKey, openGroupID)
val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID)
if (threadID < 0) {
// Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread
throw MessageReceiver.Error.NoThread
@@ -226,7 +228,9 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false)
if (runProfileUpdate) {
val profile = message.profile
if (profile != null && userPublicKey != messageSender) {
val isUserBlindedSender = messageSender == storage.getOpenGroup(threadID)?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(
IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
if (profile != null && userPublicKey != messageSender && !isUserBlindedSender) {
val profileManager = SSKEnvironment.shared.profileManager
val name = profile.displayName!!
if (name.isNotEmpty()) {
@@ -395,7 +399,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr
val plaintext = MessageDecrypter.decrypt(encryptedKeyPair, userKeyPair).first
// Parse it
val proto = SignalServiceProtos.KeyPair.parseFrom(plaintext)
val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removing05PrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray()))
val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removingIdPrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray()))
// Store it if needed
val closedGroupEncryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(groupPublicKey)
if (closedGroupEncryptionKeyPairs.contains(keyPair)) {

View File

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

View File

@@ -0,0 +1,274 @@
package org.session.libsession.messaging.sending_receiving.pollers
import com.google.protobuf.ByteString
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.jobs.OpenGroupDeleteJob
import org.session.libsession.messaging.jobs.TrimThreadJob
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.Endpoint
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.messaging.sending_receiving.MessageReceiver
import org.session.libsession.messaging.sending_receiving.handle
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.successBackground
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
class OpenGroupPoller(private val server: String, private val executorService: ScheduledExecutorService?) {
var hasStarted = false
var isCaughtUp = false
var secondToLastJob: MessageReceiveJob? = null
private var future: ScheduledFuture<*>? = null
companion object {
private const val pollInterval: Long = 4000L
const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000
}
fun startIfNeeded() {
if (hasStarted) { return }
hasStarted = true
future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS)
}
fun stop() {
future?.cancel(false)
hasStarted = false
}
fun poll(isPostCapabilitiesRetry: Boolean = false): Promise<Unit, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room }
rooms.forEach { downloadGroupAvatarIfNeeded(it) }
return OpenGroupApi.poll(rooms, server).successBackground { responses ->
responses.filterNot { it.body == null }.forEach { response ->
when (response.endpoint) {
is Endpoint.Capabilities -> {
handleCapabilities(server, response.body as OpenGroupApi.Capabilities)
}
is Endpoint.RoomPollInfo -> {
handleRoomPollInfo(server, response.endpoint.roomToken, response.body as OpenGroupApi.RoomPollInfo)
}
is Endpoint.RoomMessagesRecent -> {
handleMessages(server, response.endpoint.roomToken, response.body as List<OpenGroupApi.Message>)
}
is Endpoint.RoomMessagesSince -> {
handleMessages(server, response.endpoint.roomToken, response.body as List<OpenGroupApi.Message>)
}
is Endpoint.Inbox, is Endpoint.InboxSince -> {
handleDirectMessages(server, false, response.body as List<OpenGroupApi.DirectMessage>)
}
is Endpoint.Outbox, is Endpoint.OutboxSince -> {
handleDirectMessages(server, true, response.body as List<OpenGroupApi.DirectMessage>)
}
}
if (secondToLastJob == null && !isCaughtUp) {
isCaughtUp = true
}
}
executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
}.fail {
updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, it)
}.map { }
}
private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) {
if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) {
if (!isPostCapabilitiesRetry) {
OpenGroupApi.getCapabilities(server).map {
handleCapabilities(server, it)
}
executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS)
}
} else {
executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
}
}
private fun handleCapabilities(server: String, capabilities: OpenGroupApi.Capabilities) {
val storage = MessagingModuleConfiguration.shared.storage
storage.setServerCapabilities(server, capabilities.capabilities)
}
private fun handleRoomPollInfo(
server: String,
roomToken: String,
pollInfo: OpenGroupApi.RoomPollInfo
) {
val storage = MessagingModuleConfiguration.shared.storage
val groupId = "$server.$roomToken"
val existingOpenGroup = storage.getOpenGroup(roomToken, server)
val publicKey = existingOpenGroup?.publicKey ?: return
val openGroup = OpenGroup(
server = server,
room = pollInfo.token,
name = pollInfo.details?.name ?: "",
infoUpdates = pollInfo.details?.infoUpdates ?: 0,
publicKey = publicKey,
)
// - Open Group changes
storage.updateOpenGroup(openGroup)
// - User Count
storage.setUserCount(roomToken, server, pollInfo.activeUsers)
// - Moderators
pollInfo.details?.moderators?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.MODERATOR))
}
// - Admins
pollInfo.details?.admins?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.ADMIN))
}
}
private fun handleMessages(
server: String,
roomToken: String,
messages: List<OpenGroupApi.Message>
) {
val openGroupId = "$server.$roomToken"
val sortedMessages = messages.sortedBy { it.seqno }
sortedMessages.maxOfOrNull { it.seqno }?.let {
MessagingModuleConfiguration.shared.storage.setLastMessageServerID(roomToken, server, it)
}
val (deletions, additions) = sortedMessages.partition { it.deleted || it.data.isNullOrBlank() }
handleNewMessages(openGroupId, additions.map {
OpenGroupMessage(
serverID = it.id,
sender = it.sessionId,
sentTimestamp = (it.posted * 1000).toLong(),
base64EncodedData = it.data!!,
base64EncodedSignature = it.signature
)
})
handleDeletedMessages(openGroupId, deletions.map { it.id })
}
private fun handleDirectMessages(
server: String,
fromOutbox: Boolean,
messages: List<OpenGroupApi.DirectMessage>
) {
if (messages.isEmpty()) return
val storage = MessagingModuleConfiguration.shared.storage
val serverPublicKey = storage.getOpenGroupPublicKey(server)!!
val sortedMessages = messages.sortedBy { it.id }
val lastMessageId = sortedMessages.last().id
val mappingCache = mutableMapOf<String, BlindedIdMapping>()
if (fromOutbox) {
storage.setLastOutboxMessageId(server, lastMessageId)
} else {
storage.setLastInboxMessageId(server, lastMessageId)
}
sortedMessages.forEach {
val encodedMessage = Base64.decode(it.message)
val envelope = SignalServiceProtos.Envelope.newBuilder()
.setTimestamp(TimeUnit.SECONDS.toMillis(it.postedAt))
.setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE)
.setContent(ByteString.copyFrom(encodedMessage))
.setSource(it.sender)
.build()
try {
val (message, proto) = MessageReceiver.parse(
envelope.toByteArray(),
null,
fromOutbox,
if (fromOutbox) it.recipient else it.sender,
serverPublicKey
)
if (fromOutbox) {
val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping(
it.recipient,
server,
serverPublicKey,
true
)
val syncTarget = mapping.sessionId ?: it.recipient
if (message is VisibleMessage) {
message.syncTarget = syncTarget
} else if (message is ExpirationTimerUpdate) {
message.syncTarget = syncTarget
}
mappingCache[it.recipient] = mapping
}
MessageReceiver.handle(message, proto, null)
} catch (e: Exception) {
Log.e("Loki", "Couldn't handle direct message", e)
}
}
}
private fun handleNewMessages(openGroupID: String, messages: List<OpenGroupMessage>) {
val storage = MessagingModuleConfiguration.shared.storage
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
// check thread still exists
val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1
val threadExists = threadId >= 0
if (!hasStarted || !threadExists) { return }
val envelopes = messages.sortedBy { it.serverID!! }.map { message ->
val senderPublicKey = message.sender!!
val builder = SignalServiceProtos.Envelope.newBuilder()
builder.type = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE
builder.source = senderPublicKey
builder.sourceDevice = 1
builder.content = message.toProto().toByteString()
builder.timestamp = message.sentTimestamp
builder.build() to message.serverID
}
envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list ->
val parameters = list.map { (message, serverId) ->
MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId)
}
JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID))
}
if (envelopes.isNotEmpty()) {
JobQueue.shared.add(TrimThreadJob(threadId,openGroupID))
}
}
private fun handleDeletedMessages(openGroupID: String, serverIds: List<Long>) {
val storage = MessagingModuleConfiguration.shared.storage
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return
if (serverIds.isNotEmpty()) {
val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupID)
JobQueue.shared.add(deleteJob)
}
}
private fun downloadGroupAvatarIfNeeded(room: String) {
val storage = MessagingModuleConfiguration.shared.storage
if (storage.getGroupAvatarDownloadJob(server, room) != null) return
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.getGroup(groupId)?.let {
if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) {
JobQueue.shared.add(GroupAvatarDownloadJob(room, server))
}
}
}
}

View File

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

View File

@@ -0,0 +1,251 @@
package org.session.libsession.messaging.utilities
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.Hash
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.toHexString
import org.whispersystems.curve25519.Curve25519
import kotlin.experimental.xor
object SodiumUtilities {
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
private val curve by lazy { Curve25519.getInstance(Curve25519.BEST) }
private const val SCALAR_LENGTH: Int = 32 // crypto_core_ed25519_scalarbytes
private const val NO_CLAMP_LENGTH: Int = 32 // crypto_scalarmult_ed25519_bytes
private const val SCALAR_MULT_LENGTH: Int = 32 // crypto_scalarmult_bytes
private const val PUBLIC_KEY_LENGTH: Int = 32 // crypto_scalarmult_bytes
private const val SECRET_KEY_LENGTH: Int = 64 //crypto_sign_secretkeybytes
/* 64-byte blake2b hash then reduce to get the blinding factor */
fun generateBlindingFactor(serverPublicKey: String): ByteArray? {
// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey)
if (serverPubKeyData.size != PUBLIC_KEY_LENGTH) return null
val serverPubKeyHash = ByteArray(GenericHash.BLAKE2B_BYTES_MAX)
if (!sodium.cryptoGenericHash(serverPubKeyHash, serverPubKeyHash.size, serverPubKeyData, serverPubKeyData.size.toLong())) {
return null
}
// Reduce the server public key into an ed25519 scalar (`k`)
val x25519PublicKey = ByteArray(SCALAR_LENGTH)
sodium.cryptoCoreEd25519ScalarReduce(x25519PublicKey, serverPubKeyHash)
return if (x25519PublicKey.any { it.toInt() != 0 }) {
x25519PublicKey
} else null
}
/*
Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
same secret scalar secret (and so this is just the most convenient way to get 'a' out of
a sodium Ed25519 secret key)
*/
fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? {
// a = s.to_curve25519_private_key().encode()
val aBytes = ByteArray(SCALAR_MULT_LENGTH)
return if (sodium.convertSecretKeyEd25519ToCurve25519(aBytes, secretKey)) {
aBytes
} else null
}
/* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */
fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? {
if (edKeyPair.publicKey.asBytes.size != PUBLIC_KEY_LENGTH || edKeyPair.secretKey.asBytes.size != SECRET_KEY_LENGTH) return null
val kBytes = generateBlindingFactor(serverPublicKey) ?: return null
val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes) ?: return null
// Generate the blinded key pair `ka`, `kA`
val kaBytes = ByteArray(SECRET_KEY_LENGTH)
sodium.cryptoCoreEd25519ScalarMul(kaBytes, kBytes, aBytes)
if (kaBytes.all { it.toInt() == 0 }) return null
val kABytes = ByteArray(PUBLIC_KEY_LENGTH)
return if (sodium.cryptoScalarMultEd25519BaseNoClamp(kABytes, kaBytes)) {
KeyPair(Key.fromBytes(kABytes), Key.fromBytes(kaBytes))
} else {
null
}
}
/*
Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the
construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded
pubkeys (this doesn't affect verification at all)
*/
fun sogsSignature(
message: ByteArray,
secretKey: ByteArray,
blindedSecretKey: ByteArray, /*ka*/
blindedPublicKey: ByteArray /*kA*/
): ByteArray? {
// H_rh = sha512(s.encode()).digest()[32:]
val digest = ByteArray(Hash.SHA512_BYTES)
val h_rh = if (sodium.cryptoHashSha512(digest, secretKey, secretKey.size.toLong())) {
digest.takeLast(32).toByteArray()
} else return null
// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts))
val rHash = sha512Multipart(listOf(h_rh, blindedPublicKey, message)) ?: return null
val r = ByteArray(SCALAR_LENGTH)
sodium.cryptoCoreEd25519ScalarReduce(r, rHash)
if (r.all { it.toInt() == 0 }) return null
// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r)
val sig_R = ByteArray(NO_CLAMP_LENGTH)
if (!sodium.cryptoScalarMultEd25519BaseNoClamp(sig_R, r)) return null
// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts))
val hRamHash = sha512Multipart(listOf(sig_R, blindedPublicKey, message)) ?: return null
val hRam = ByteArray(SCALAR_LENGTH)
sodium.cryptoCoreEd25519ScalarReduce(hRam, hRamHash)
if (hRam.all { it.toInt() == 0 }) return null
// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka))
val sig_sMul = ByteArray(SCALAR_LENGTH)
val sig_s = ByteArray(SCALAR_LENGTH)
sodium.cryptoCoreEd25519ScalarMul(sig_sMul, hRam, blindedSecretKey)
if (sig_sMul.any { it.toInt() != 0 }) {
sodium.cryptoCoreEd25519ScalarAdd(sig_s, r, sig_sMul)
if (sig_s.all { it.toInt() == 0 }) return null
} else return null
return sig_R + sig_s
}
private fun sha512Multipart(parts: List<ByteArray>): ByteArray? {
val state = Hash.State512()
sodium.cryptoHashSha512Init(state)
parts.forEach {
sodium.cryptoHashSha512Update(state, it, it.size.toLong())
}
val finalHash = ByteArray(Hash.SHA512_BYTES)
return if (sodium.cryptoHashSha512Final(state, finalHash)) {
finalHash
} else null
}
/* Combines two keys (`kA`) */
fun combineKeys(lhsKey: ByteArray, rhsKey: ByteArray): ByteArray? {
val kA = ByteArray(NO_CLAMP_LENGTH)
return if (sodium.cryptoScalarMultEd25519NoClamp(kA, lhsKey, rhsKey)) {
kA
} else null
}
/*
Calculate a shared secret for a message from A to B:
BLAKE2b(a kB || kA || kB)
The receiver can calculate the same value via:
BLAKE2b(b kA || kA || kB)
*/
fun sharedBlindedEncryptionKey(
secretKey: ByteArray,
otherBlindedPublicKey: ByteArray,
kA: ByteArray, /*fromBlindedPublicKey*/
kB: ByteArray /*toBlindedPublicKey*/
): ByteArray? {
val aBytes = generatePrivateKeyScalar(secretKey) ?: return null
val combinedKeyBytes = combineKeys(aBytes, otherBlindedPublicKey) ?: return null
val outputHash = ByteArray(GenericHash.KEYBYTES)
val inputBytes = combinedKeyBytes + kA + kB
return if (sodium.cryptoGenericHash(outputHash, outputHash.size, inputBytes, inputBytes.size.toLong())) {
outputHash
} else null
}
/* This method should be used to check if a users standard sessionId matches a blinded one */
fun sessionId(
standardSessionId: String,
blindedSessionId: String,
serverPublicKey: String
): Boolean {
// Only support generating blinded keys for standard session ids
val sessionId = SessionId(standardSessionId)
if (sessionId.prefix != IdPrefix.STANDARD) return false
val blindedId = SessionId(blindedSessionId)
if (blindedId.prefix != IdPrefix.BLINDED) return false
val k = generateBlindingFactor(serverPublicKey) ?: return false
// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys;
// the first is the positive (which is what Signal's XEd25519 conversion always uses)
val xEd25519Key = curve.convertToEd25519PublicKey(Key.fromHexString(sessionId.publicKey).asBytes)
// Blind the positive public key
val pk1 = combineKeys(k, xEd25519Key) ?: return false
// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2
// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000])
val pk2 = pk1.take(31).toByteArray() + listOf(pk1.last().xor(128.toByte())).toByteArray()
return SessionId(IdPrefix.BLINDED, pk1).publicKey == blindedId.publicKey ||
SessionId(IdPrefix.BLINDED, pk2).publicKey == blindedId.publicKey
}
fun encrypt(message: ByteArray, secretKey: ByteArray, nonce: ByteArray, additionalData: ByteArray? = null): ByteArray? {
val authenticatedCipherText = ByteArray(message.size + AEAD.CHACHA20POLY1305_ABYTES)
return if (sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
authenticatedCipherText,
longArrayOf(0),
message,
message.size.toLong(),
additionalData,
(additionalData?.size ?: 0).toLong(),
null,
nonce,
secretKey
)
) {
authenticatedCipherText
} else null
}
fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? {
val plaintextSize = ciphertext.size - AEAD.CHACHA20POLY1305_ABYTES
val plaintext = ByteArray(plaintextSize)
return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
plaintext,
longArrayOf(plaintextSize.toLong()),
null,
ciphertext,
ciphertext.size.toLong(),
null,
0L,
nonce,
decryptionKey
)
) {
plaintext
} else null
}
fun toX25519(ed25519PublicKey: ByteArray): ByteArray? {
val x25519PublicKey = ByteArray(PUBLIC_KEY_LENGTH)
return if (sodium.convertPublicKeyEd25519ToCurve25519(x25519PublicKey, ed25519PublicKey)) {
x25519PublicKey
} else null
}
}
class SessionId {
var prefix: IdPrefix?
var publicKey: String
constructor(id: String) {
prefix = IdPrefix.fromValue(id)
publicKey = id.drop(2)
}
constructor(prefix: IdPrefix, publicKey: ByteArray) {
this.prefix = prefix
this.publicKey = publicKey.toHexString()
}
val hexString
get() = prefix?.value + publicKey
}

View File

@@ -1,12 +1,13 @@
package org.session.libsession.snode
import nl.komponents.kovenant.Deferred
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.messaging.file_server.FileServerApi
import org.session.libsession.utilities.AESGCM
import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsession.utilities.getBodyForOnionRequest
@@ -76,7 +77,8 @@ object OnionRequestAPI {
const val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
// endregion
class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String)
class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination)
open class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String)
: Exception("HTTP request failed at destination ($destination) with status code $statusCode.")
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
@@ -100,7 +102,8 @@ object OnionRequestAPI {
ThreadUtils.queue { // No need to block the shared context for this
val url = "${snode.address}:${snode.port}/get_stats/v1"
try {
val json = HTTP.execute(HTTP.Verb.GET, url, 3)
val response = HTTP.execute(HTTP.Verb.GET, url, 3).decodeToString()
val json = JsonUtil.fromJson(response, Map::class.java)
val version = json["version"] as? String
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue }
if (version >= "2.0.7") {
@@ -207,26 +210,30 @@ object OnionRequestAPI {
}
OnionRequestAPI.guardSnodes = guardSnodes
fun getPath(paths: List<Path>): Path {
if (snodeToExclude != null) {
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
return if (snodeToExclude != null) {
paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
} else {
return paths.getRandomElement()
paths.getRandomElement()
}
}
if (paths.count() >= targetPathCount) {
return Promise.of(getPath(paths))
} else if (paths.isNotEmpty()) {
if (paths.any { !it.contains(snodeToExclude) }) {
buildPaths(paths) // Re-build paths in the background
when {
paths.count() >= targetPathCount -> {
return Promise.of(getPath(paths))
} else {
return buildPaths(paths).map { newPaths ->
getPath(newPaths)
}
paths.isNotEmpty() -> {
return if (paths.any { !it.contains(snodeToExclude) }) {
buildPaths(paths) // Re-build paths in the background
Promise.of(getPath(paths))
} else {
buildPaths(paths).map { newPaths ->
getPath(newPaths)
}
}
}
} else {
return buildPaths(listOf()).map { newPaths ->
getPath(newPaths)
else -> {
return buildPaths(listOf()).map { newPaths ->
getPath(newPaths)
}
}
}
}
@@ -268,7 +275,11 @@ object OnionRequestAPI {
/**
* Builds an onion around `payload` and returns the result.
*/
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise<OnionBuildingResult, Exception> {
private fun buildOnionForDestination(
payload: ByteArray,
destination: Destination,
version: Version
): Promise<OnionBuildingResult, Exception> {
lateinit var guardSnode: Snode
lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination
lateinit var encryptionResult: EncryptionResult
@@ -279,19 +290,19 @@ object OnionRequestAPI {
return getPath(snodeToExclude).bind { path ->
guardSnode = path.first()
// Encrypt in reverse order, i.e. the destination first
OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind { r ->
OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version).bind { r ->
destinationSymmetricKey = r.symmetricKey
// Recursively encrypt the layers of the onion (again in reverse order)
encryptionResult = r
@Suppress("NAME_SHADOWING") var path = path
var rhs = destination
fun addLayer(): Promise<EncryptionResult, Exception> {
if (path.isEmpty()) {
return Promise.of(encryptionResult)
return if (path.isEmpty()) {
Promise.of(encryptionResult)
} else {
val lhs = Destination.Snode(path.last())
path = path.dropLast(1)
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r ->
OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind { r ->
encryptionResult = r
rhs = lhs
addLayer()
@@ -306,16 +317,20 @@ object OnionRequestAPI {
/**
* Sends an onion request to `destination`. Builds new paths as needed.
*/
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>): Promise<Map<*, *>, Exception> {
val deferred = deferred<Map<*, *>, Exception>()
private fun sendOnionRequest(
destination: Destination,
payload: ByteArray,
version: Version
): Promise<OnionResponse, Exception> {
val deferred = deferred<OnionResponse, Exception>()
var guardSnode: Snode? = null
buildOnionForDestination(payload, destination).success { result ->
buildOnionForDestination(payload, destination, version).success { result ->
guardSnode = result.guardSnode
val nonNullGuardSnode = result.guardSnode
val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2"
val finalEncryptionResult = result.finalEncryptionResult
val onion = finalEncryptionResult.ciphertext
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPIV2.maxFileSize.toDouble()) {
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.maxFileSize.toDouble()) {
Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
}
@Suppress("NAME_SHADOWING") val parameters = mapOf(
@@ -330,65 +345,8 @@ object OnionRequestAPI {
val destinationSymmetricKey = result.destinationSymmetricKey
ThreadUtils.queue {
try {
val json = HTTP.execute(HTTP.Verb.POST, url, body)
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@queue deferred.reject(Exception("Invalid JSON"))
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
try {
val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey)
try {
@Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java)
val statusCode = json["status_code"] as? Int ?: json["status"] as Int
if (statusCode == 406) {
@Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." )
val exception = HTTPRequestFailedAtDestinationException(statusCode, body, destination.description)
return@queue deferred.reject(exception)
} else if (json["body"] != null) {
@Suppress("NAME_SHADOWING") val body: Map<*, *>
if (json["body"] is Map<*, *>) {
body = json["body"] as Map<*, *>
} else {
val bodyAsString = json["body"] as String
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
}
if (body["t"] != null) {
val timestamp = body["t"] as Long
val offset = timestamp - Date().time
SnodeAPI.clockOffset = offset
}
if (body.containsKey("hf")) {
@Suppress("UNCHECKED_CAST")
val currentHf = body["hf"] as List<Int>
if (currentHf.size < 2) {
Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number")
} else {
val hf = currentHf[0]
val sf = currentHf[1]
val newForkInfo = ForkInfo(hf, sf)
if (newForkInfo > SnodeAPI.forkInfo) {
SnodeAPI.forkInfo = ForkInfo(hf,sf)
} else if (newForkInfo < SnodeAPI.forkInfo) {
Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}")
}
}
}
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, body, destination.description)
return@queue deferred.reject(exception)
}
deferred.resolve(body)
} else {
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, json, destination.description)
return@queue deferred.reject(exception)
}
deferred.resolve(json)
}
} catch (exception: Exception) {
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
}
} catch (exception: Exception) {
deferred.reject(exception)
}
val response = HTTP.execute(HTTP.Verb.POST, url, body)
handleResponse(response, destinationSymmetricKey, destination, version, deferred)
} catch (exception: Exception) {
deferred.reject(exception)
}
@@ -459,9 +417,19 @@ object OnionRequestAPI {
/**
* Sends an onion request to `snode`. Builds new paths as needed.
*/
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String? = null): Promise<Map<*, *>, Exception> {
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
internal fun sendOnionRequest(
method: Snode.Method,
parameters: Map<*, *>,
snode: Snode,
version: Version,
publicKey: String? = null
): Promise<OnionResponse, Exception> {
val payload = mapOf(
"method" to method.rawValue,
"params" to parameters
)
val payloadData = JsonUtil.toJson(payload).toByteArray()
return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception ->
val error = when (exception) {
is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)
is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)
@@ -477,27 +445,228 @@ object OnionRequestAPI {
*
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
*/
fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc"): Promise<Map<*, *>, Exception> {
val headers = request.getHeadersForOnionRequest()
fun sendOnionRequest(
request: Request,
server: String,
x25519PublicKey: String,
version: Version = Version.V4
): Promise<OnionResponse, Exception> {
val url = request.url()
val urlAsString = url.toString()
val host = url.host()
val endpoint = when {
server.count() < urlAsString.count() -> urlAsString.substringAfter(server).removePrefix("/")
else -> ""
}
val body = request.getBodyForOnionRequest() ?: "null"
val payload = mapOf(
"body" to body,
"endpoint" to endpoint,
"method" to request.method(),
"headers" to headers
)
val destination = Destination.Server(host, target, x25519PublicKey, url.scheme(), url.port())
return sendOnionRequest(destination, payload).recover { exception ->
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.")
val payload = generatePayload(request, server, version)
val destination = Destination.Server(url.host(), version.value, x25519PublicKey, url.scheme(), url.port())
return sendOnionRequest(destination, payload, version).recover { exception ->
Log.d("Loki", "Couldn't reach server: $url due to error: $exception.")
throw exception
}
}
private fun generatePayload(request: Request, server: String, version: Version): ByteArray {
val headers = request.getHeadersForOnionRequest().toMutableMap()
val url = request.url()
val urlAsString = url.toString()
val body = request.getBodyForOnionRequest() ?: "null"
val endpoint = when {
server.count() < urlAsString.count() -> urlAsString.substringAfter(server)
else -> ""
}
return if (version == Version.V4) {
if (request.body() != null &&
headers.keys.find { it.equals("Content-Type", true) } == null) {
headers["Content-Type"] = "application/json"
}
val requestPayload = mapOf(
"endpoint" to endpoint,
"method" to request.method(),
"headers" to headers
)
val requestData = JsonUtil.toJson(requestPayload).toByteArray()
val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII)
val suffixData = "e".toByteArray(Charsets.US_ASCII)
if (request.body() != null) {
val bodyData = body.toString().toByteArray()
val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII)
prefixData + requestData + bodyLengthData + bodyData + suffixData
} else {
prefixData + requestData + suffixData
}
} else {
val payload = mapOf(
"body" to body,
"endpoint" to endpoint.removePrefix("/"),
"method" to request.method(),
"headers" to headers
)
JsonUtil.toJson(payload).toByteArray()
}
}
private fun handleResponse(
response: ByteArray,
destinationSymmetricKey: ByteArray,
destination: Destination,
version: Version,
deferred: Deferred<OnionResponse, Exception>
) {
if (version == Version.V4) {
try {
if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response"))
// The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into
// parts to properly process it
val plaintext = AESGCM.decrypt(response, destinationSymmetricKey)
if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response"))
val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) }
val infoLenSlice = plaintext.slice(1 until infoSepIdx)
val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull()
if (infoLenSlice.size <= 1 || infoLength == null) return deferred.reject(Exception("Invalid response"))
val infoStartIndex = "l$infoLength".length + 1
val infoEndIndex = infoStartIndex + infoLength
val info = plaintext.slice(infoStartIndex until infoEndIndex)
val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java)
when (val statusCode = responseInfo["code"].toString().toInt()) {
// Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case)
406, 425 -> {
@Suppress("NAME_SHADOWING")
val exception = HTTPRequestFailedAtDestinationException(
statusCode,
mapOf("result" to "Your clock is out of sync with the service node network."),
destination.description
)
return deferred.reject(exception)
}
// Handle error status codes
!in 200..299 -> {
val responseBody = if (destination is Destination.Server && statusCode == 400) plaintext.getBody(infoLength, infoEndIndex) else null
val requireBlinding = "Invalid authentication: this server requires the use of blinded ids"
val exception = if (responseBody != null && responseBody.decodeToString() == requireBlinding) {
HTTPRequestFailedBlindingRequiredException(400, responseInfo, destination.description)
} else HTTPRequestFailedAtDestinationException(
statusCode,
responseInfo,
destination.description
)
return deferred.reject(exception)
}
}
val responseBody = plaintext.getBody(infoLength, infoEndIndex)
// If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo
if (responseBody.isEmpty()) {
return deferred.resolve(OnionResponse(responseInfo, null))
}
return deferred.resolve(OnionResponse(responseInfo, responseBody))
} catch (exception: Exception) {
deferred.reject(exception)
}
} else {
val json = try {
JsonUtil.fromJson(response, Map::class.java)
} catch (exception: Exception) {
mapOf( "result" to response.decodeToString())
}
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON"))
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
try {
val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey)
try {
@Suppress("NAME_SHADOWING") val json =
JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java)
val statusCode = json["status_code"] as? Int ?: json["status"] as Int
when {
statusCode == 406 -> {
@Suppress("NAME_SHADOWING")
val body =
mapOf("result" to "Your clock is out of sync with the service node network.")
val exception = HTTPRequestFailedAtDestinationException(
statusCode,
body,
destination.description
)
return deferred.reject(exception)
}
json["body"] != null -> {
@Suppress("NAME_SHADOWING")
val body = if (json["body"] is Map<*, *>) {
json["body"] as Map<*, *>
} else {
val bodyAsString = json["body"] as String
JsonUtil.fromJson(bodyAsString, Map::class.java)
}
if (body["t"] != null) {
val timestamp = body["t"] as Long
val offset = timestamp - Date().time
SnodeAPI.clockOffset = offset
}
if (body.containsKey("hf")) {
@Suppress("UNCHECKED_CAST")
val currentHf = body["hf"] as List<Int>
if (currentHf.size < 2) {
Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number")
} else {
val hf = currentHf[0]
val sf = currentHf[1]
val newForkInfo = ForkInfo(hf, sf)
if (newForkInfo > SnodeAPI.forkInfo) {
SnodeAPI.forkInfo = ForkInfo(hf,sf)
} else if (newForkInfo < SnodeAPI.forkInfo) {
Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}")
}
}
}
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(
statusCode,
body,
destination.description
)
return deferred.reject(exception)
}
deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray()))
}
else -> {
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(
statusCode,
json,
destination.description
)
return deferred.reject(exception)
}
deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray()))
}
}
} catch (exception: Exception) {
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}
}
private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArray {
// If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo
val infoLengthStringLength = infoLength.toString().length
if (size <= infoLength + infoLengthStringLength + 2/*l and e bytes*/) {
return byteArrayOf()
}
// Extract the response data as well
val dataSlice = slice(infoEndIndex + 1 until size - 1)
val dataSepIdx = dataSlice.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) }
val responseBody = dataSlice.slice(dataSepIdx + 1 until dataSlice.size)
return responseBody.toByteArray()
}
// endregion
}
enum class Version(val value: String) {
V2("/loki/v2/lsrpc"),
V3("/loki/v3/lsrpc"),
V4("/oxen/v4/lsrpc");
}
data class OnionResponse(
val info: Map<*, *>,
val body: ByteArray? = null
)

View File

@@ -2,6 +2,7 @@ package org.session.libsession.snode
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.snode.OnionRequestAPI.Destination
import org.session.libsession.utilities.AESGCM
import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsignal.utilities.toHexString
@@ -31,25 +32,29 @@ object OnionRequestEncryption {
/**
* Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
*/
internal fun encryptPayloadForDestination(payload: Map<*, *>, destination: OnionRequestAPI.Destination): Promise<EncryptionResult, Exception> {
internal fun encryptPayloadForDestination(
payload: ByteArray,
destination: Destination,
version: Version
): Promise<EncryptionResult, Exception> {
val deferred = deferred<EncryptionResult, Exception>()
ThreadUtils.queue {
try {
// Wrapping isn't needed for file server or open group onion requests
when (destination) {
is OnionRequestAPI.Destination.Snode -> {
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key
val payloadAsData = JsonUtil.toJson(payload).toByteArray()
val plaintext = encode(payloadAsData, mapOf( "headers" to "" ))
val result = AESGCM.encrypt(plaintext, snodeX25519PublicKey)
deferred.resolve(result)
}
is OnionRequestAPI.Destination.Server -> {
val plaintext = JsonUtil.toJson(payload).toByteArray()
val result = AESGCM.encrypt(plaintext, destination.x25519PublicKey)
deferred.resolve(result)
val plaintext = if (version == Version.V4) {
payload
} else {
// Wrapping isn't needed for file server or open group onion requests
when (destination) {
is Destination.Snode -> encode(payload, mapOf("headers" to ""))
is Destination.Server -> payload
}
}
val x25519PublicKey = when (destination) {
is Destination.Snode -> destination.snode.publicKeySet!!.x25519Key
is Destination.Server -> destination.x25519PublicKey
}
val result = AESGCM.encrypt(plaintext, x25519PublicKey)
deferred.resolve(result)
} catch (exception: Exception) {
deferred.reject(exception)
}
@@ -60,17 +65,16 @@ object OnionRequestEncryption {
/**
* Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
*/
internal fun encryptHop(lhs: OnionRequestAPI.Destination, rhs: OnionRequestAPI.Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
internal fun encryptHop(lhs: Destination, rhs: Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
val deferred = deferred<EncryptionResult, Exception>()
ThreadUtils.queue {
try {
val payload: MutableMap<String, Any>
when (rhs) {
is OnionRequestAPI.Destination.Snode -> {
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
val payload: MutableMap<String, Any> = when (rhs) {
is Destination.Snode -> {
mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
}
is OnionRequestAPI.Destination.Server -> {
payload = mutableMapOf(
is Destination.Server -> {
mutableMapOf(
"host" to rhs.host,
"target" to rhs.target,
"method" to "POST",
@@ -80,13 +84,12 @@ object OnionRequestEncryption {
}
}
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
val x25519PublicKey: String
when (lhs) {
is OnionRequestAPI.Destination.Snode -> {
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
val x25519PublicKey = when (lhs) {
is Destination.Snode -> {
lhs.snode.publicKeySet!!.x25519Key
}
is OnionRequestAPI.Destination.Server -> {
x25519PublicKey = lhs.x25519PublicKey
is Destination.Server -> {
lhs.x25519PublicKey
}
}
val plaintext = encode(previousEncryptionResult.ciphertext, payload)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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