This commit is contained in:
Anton Chekulaev 2020-12-03 15:24:40 +11:00
commit 841e4aa493
55 changed files with 4098 additions and 54 deletions

View File

@ -33,12 +33,36 @@ android {
} }
dependencies { dependencies {
// Local:
implementation project(":libsignal")
// Remote:
implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1' implementation 'com.google.android.material:material:1.2.1'
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
testImplementation 'junit:junit:4.+' testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
// from libsignal:
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.googlecode.libphonenumber:libphonenumber:8.10.7"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "org.whispersystems:curve25519-java:$curve25519Version"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "org.threeten:threetenbp:1.3.6"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
testImplementation "junit:junit:3.8.2"
testImplementation "org.assertj:assertj-core:1.7.1"
testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0"
} }

View File

@ -0,0 +1,22 @@
package org.session.libsession.messaging
import org.session.libsignal.libsignal.loki.SessionResetProtocol
import org.session.libsignal.libsignal.state.*
import org.session.libsignal.metadata.certificate.CertificateValidator
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol
class Configuration(val storage: StorageProtocol, val signalStorage: SignalProtocolStore, val sskDatabase: SharedSenderKeysDatabaseProtocol, val sessionResetImp: SessionResetProtocol, val certificateValidator: CertificateValidator) {
companion object {
lateinit var shared: Configuration
fun configure(storage: StorageProtocol,
signalStorage: SignalProtocolStore,
sskDatabase: SharedSenderKeysDatabaseProtocol,
sessionResetImp: SessionResetProtocol,
certificateValidator: CertificateValidator
) {
if (Companion::shared.isInitialized) { return }
shared = Configuration(storage, signalStorage, sskDatabase, sessionResetImp, certificateValidator)
}
}
}

View File

@ -0,0 +1,75 @@
package org.session.libsession.messaging
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.opengroups.OpenGroup
import org.session.libsignal.libsignal.ecc.ECKeyPair
import org.session.libsignal.libsignal.ecc.ECPrivateKey
interface StorageProtocol {
// General
fun getUserPublicKey(): String?
fun getUserKeyPair(): ECKeyPair?
fun getUserDisplayName(): String?
fun getUserProfileKey(): ByteArray?
fun getUserProfilePictureURL(): String?
// Signal Protocol
fun getOrGenerateRegistrationID(): Int //TODO needs impl
// Shared Sender Keys
fun getClosedGroupPrivateKey(publicKey: String): ECPrivateKey?
fun isClosedGroup(publicKey: String): Boolean
// Jobs
fun persist(job: Job)
fun markJobAsSucceeded(job: Job)
fun markJobAsFailed(job: Job)
fun getAllPendingJobs(type: String): List<Job>
fun getAttachmentUploadJob(attachmentID: String): AttachmentUploadJob?
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
fun resumeMessageSendJobIfNeeded(messageSendJobID: String)
fun isJobCanceled(job: Job): Boolean
// Authorization
fun getAuthToken(server: String): String?
fun setAuthToken(server: String, newValue: String?)
fun removeAuthToken(server: String)
// Open Groups
fun getOpenGroup(threadID: String): OpenGroup?
fun getThreadID(openGroupID: String): String?
// Open Group Public Keys
fun getOpenGroupPublicKey(server: String): String?
fun setOpenGroupPublicKey(server: String, newValue: String)
// Last Message Server ID
fun getLastMessageServerID(group: Long, server: String): Long?
fun setLastMessageServerID(group: Long, server: String, newValue: Long)
fun removeLastMessageServerID(group: Long, server: String)
// Last Deletion Server ID
fun getLastDeletionServerID(group: Long, server: String): Long?
fun setLastDeletionServerID(group: Long, server: String, newValue: Long)
fun removeLastDeletionServerID(group: Long, server: String)
// Open Group Metadata
fun setUserCount(group: Long, server: String, newValue: Int)
fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String)
fun getOpenGroupProfilePictureURL(group: Long, server: String): String?
fun updateTitle(groupID: String, newValue: String)
fun updateProfilePicture(groupID: String, newValue: ByteArray)
fun getSessionRequestSentTimestamp(publicKey: String): Long?
fun setSessionRequestSentTimestamp(publicKey: String, newValue: Long)
fun getSessionRequestProcessedTimestamp(publicKey: String): Long?
fun setSessionRequestProcessedTimestamp(publicKey: String, newValue: Long)
}

View File

@ -0,0 +1,262 @@
package org.session.libsession.messaging.fileserver
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.libsignal.util.Hex
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.api.SnodeAPI
import org.session.libsignal.service.loki.api.LokiDotNetAPI
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.protocol.shelved.multidevice.DeviceLink
import org.session.libsignal.service.loki.utilities.*
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.set
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, database) {
companion object {
// region Settings
/**
* Deprecated.
*/
private val deviceLinkType = "network.loki.messenger.devicemapping"
/**
* Deprecated.
*/
private val deviceLinkRequestCache = ConcurrentHashMap<String, Promise<Set<DeviceLink>, Exception>>()
/**
* Deprecated.
*/
private val deviceLinkUpdateInterval = 60 * 1000
private val lastDeviceLinkUpdate = ConcurrentHashMap<String, Long>()
internal val fileServerPublicKey = "62509D59BDEEC404DD0D489C1E15BA8F94FD3D619B01C1BF48A9922BFCB7311C"
internal val maxRetryCount = 4
public val maxFileSize = 10_000_000 // 10 MB
/**
* The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes
* is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP
* request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also
* be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when
* uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only
* possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds.
*/
public val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5?
public val fileStorageBucketURL = "https://file-static.lokinet.org"
// endregion
// region Initialization
lateinit var shared: FileServerAPI
/**
* Must be called before `LokiAPI` is used.
*/
fun configure(userPublicKey: String, userPrivateKey: ByteArray, database: LokiAPIDatabaseProtocol) {
if (Companion::shared.isInitialized) { return }
val server = "https://file.getsession.org"
shared = FileServerAPI(server, userPublicKey, userPrivateKey, database)
}
// endregion
}
// region Device Link Update Result
sealed class DeviceLinkUpdateResult {
class Success(val publicKey: String, val deviceLinks: Set<DeviceLink>) : DeviceLinkUpdateResult()
class Failure(val publicKey: String, val error: Exception) : DeviceLinkUpdateResult()
}
// endregion
// region API
public fun hasDeviceLinkCacheExpired(referenceTime: Long = System.currentTimeMillis(), publicKey: String): Boolean {
return !lastDeviceLinkUpdate.containsKey(publicKey) || (referenceTime - lastDeviceLinkUpdate[publicKey]!! > deviceLinkUpdateInterval)
}
fun getDeviceLinks(publicKey: String, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
return Promise.of(setOf())
/*
if (deviceLinkRequestCache.containsKey(publicKey) && !isForcedUpdate) {
val result = deviceLinkRequestCache[publicKey]
if (result != null) { return result } // A request was already pending
}
val promise = getDeviceLinks(setOf(publicKey), isForcedUpdate)
deviceLinkRequestCache[publicKey] = promise
promise.always {
deviceLinkRequestCache.remove(publicKey)
}
return promise
*/
}
fun getDeviceLinks(publicKeys: Set<String>, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
return Promise.of(setOf())
/*
val validPublicKeys = publicKeys.filter { PublicKeyValidation.isValid(it) }
val now = System.currentTimeMillis()
// IMPORTANT: Don't fetch device links for the current user (i.e. don't remove the it != userHexEncodedPublicKey) check below
val updatees = validPublicKeys.filter { it != userPublicKey && (hasDeviceLinkCacheExpired(now, it) || isForcedUpdate) }.toSet()
val cachedDeviceLinks = validPublicKeys.minus(updatees).flatMap { database.getDeviceLinks(it) }.toSet()
if (updatees.isEmpty()) {
return Promise.of(cachedDeviceLinks)
} else {
return getUserProfiles(updatees, server, true).map(SnodeAPI.sharedContext) { data ->
data.map dataMap@ { node ->
val publicKey = node["username"] as String
val annotations = node["annotations"] as List<Map<*, *>>
val deviceLinksAnnotation = annotations.find {
annotation -> (annotation["type"] as String) == deviceLinkType
} ?: return@dataMap DeviceLinkUpdateResult.Success(publicKey, setOf())
val value = deviceLinksAnnotation["value"] as Map<*, *>
val deviceLinksAsJSON = value["authorisations"] as List<Map<*, *>>
val deviceLinks = deviceLinksAsJSON.mapNotNull { deviceLinkAsJSON ->
try {
val masterPublicKey = deviceLinkAsJSON["primaryDevicePubKey"] as String
val slavePublicKey = deviceLinkAsJSON["secondaryDevicePubKey"] as String
var requestSignature: ByteArray? = null
var authorizationSignature: ByteArray? = null
if (deviceLinkAsJSON["requestSignature"] != null) {
val base64EncodedSignature = deviceLinkAsJSON["requestSignature"] as String
requestSignature = Base64.decode(base64EncodedSignature)
}
if (deviceLinkAsJSON["grantSignature"] != null) {
val base64EncodedSignature = deviceLinkAsJSON["grantSignature"] as String
authorizationSignature = Base64.decode(base64EncodedSignature)
}
val deviceLink = DeviceLink(masterPublicKey, slavePublicKey, requestSignature, authorizationSignature)
val isValid = deviceLink.verify()
if (!isValid) {
Log.d("Loki", "Ignoring invalid device link: $deviceLinkAsJSON.")
return@mapNotNull null
}
deviceLink
} catch (e: Exception) {
Log.d("Loki", "Failed to parse device links for $publicKey from $deviceLinkAsJSON due to error: $e.")
null
}
}.toSet()
DeviceLinkUpdateResult.Success(publicKey, deviceLinks)
}
}.recover { e ->
publicKeys.map { DeviceLinkUpdateResult.Failure(it, e) }
}.success { updateResults ->
for (updateResult in updateResults) {
if (updateResult is DeviceLinkUpdateResult.Success) {
database.clearDeviceLinks(updateResult.publicKey)
updateResult.deviceLinks.forEach { database.addDeviceLink(it) }
} else {
// Do nothing
}
}
}.map(SnodeAPI.sharedContext) { updateResults ->
val deviceLinks = mutableListOf<DeviceLink>()
for (updateResult in updateResults) {
when (updateResult) {
is DeviceLinkUpdateResult.Success -> {
lastDeviceLinkUpdate[updateResult.publicKey] = now
deviceLinks.addAll(updateResult.deviceLinks)
}
is DeviceLinkUpdateResult.Failure -> {
if (updateResult.error is SnodeAPI.Error.ParsingFailed) {
lastDeviceLinkUpdate[updateResult.publicKey] = now // Don't infinitely update in case of a parsing failure
}
deviceLinks.addAll(database.getDeviceLinks(updateResult.publicKey)) // Fall back on cached device links in case of a failure
}
}
}
// Updatees that didn't show up in the response provided by the file server are assumed to not have any device links
val excludedUpdatees = updatees.filter { updatee ->
updateResults.find { updateResult ->
when (updateResult) {
is DeviceLinkUpdateResult.Success -> updateResult.publicKey == updatee
is DeviceLinkUpdateResult.Failure -> updateResult.publicKey == updatee
}
} == null
}
excludedUpdatees.forEach {
lastDeviceLinkUpdate[it] = now
}
deviceLinks.union(cachedDeviceLinks)
}.recover {
publicKeys.flatMap { database.getDeviceLinks(it) }.toSet()
}
}
*/
}
fun setDeviceLinks(deviceLinks: Set<DeviceLink>): Promise<Unit, Exception> {
return Promise.of(Unit)
/*
val isMaster = deviceLinks.find { it.masterPublicKey == userPublicKey } != null
val deviceLinksAsJSON = deviceLinks.map { it.toJSON() }
val value = if (deviceLinks.isNotEmpty()) mapOf( "isPrimary" to isMaster, "authorisations" to deviceLinksAsJSON ) else null
val annotation = mapOf( "type" to deviceLinkType, "value" to value )
val parameters = mapOf( "annotations" to listOf( annotation ) )
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.PATCH, server, "/users/me", parameters = parameters)
}.map { Unit }
*/
}
fun addDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
return Promise.of(Unit)
/*
Log.d("Loki", "Updating device links.")
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
val mutableDeviceLinks = deviceLinks.toMutableSet()
mutableDeviceLinks.add(deviceLink)
setDeviceLinks(mutableDeviceLinks)
}.success {
database.addDeviceLink(deviceLink)
}.map { Unit }
*/
}
fun removeDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
return Promise.of(Unit)
/*
Log.d("Loki", "Updating device links.")
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
val mutableDeviceLinks = deviceLinks.toMutableSet()
mutableDeviceLinks.remove(deviceLink)
setDeviceLinks(mutableDeviceLinks)
}.success {
database.removeDeviceLink(deviceLink)
}.map { Unit }
*/
}
// endregion
// region Open Group Server Public Key
fun getPublicKeyForOpenGroupServer(openGroupServer: String): Promise<String, Exception> {
val publicKey = database.getOpenGroupPublicKey(openGroupServer)
if (publicKey != null && PublicKeyValidation.isValid(publicKey, 64, false)) {
return Promise.of(publicKey)
} else {
val url = "$server/loki/v1/getOpenGroupKey/${URL(openGroupServer).host}"
val request = Request.Builder().url(url)
request.addHeader("Content-Type", "application/json")
request.addHeader("Authorization", "Bearer loki") // Tokenless request; use a dummy token
return OnionRequestAPI.sendOnionRequest(request.build(), server, fileServerPublicKey).map { json ->
try {
val bodyAsString = json["data"] as String
val body = JsonUtil.fromJson(bodyAsString)
val base64EncodedPublicKey = body.get("data").asText()
val prefixedPublicKey = Base64.decode(base64EncodedPublicKey)
val hexEncodedPrefixedPublicKey = prefixedPublicKey.toHexString()
val result = hexEncodedPrefixedPublicKey.removing05PrefixIfNeeded()
database.setOpenGroupPublicKey(openGroupServer, result)
result
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse open group public key from: $json.")
throw exception
}
}
}
}
}

View File

@ -1,4 +1,17 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
class AttachmentDownloadJob: Job { class AttachmentDownloadJob: Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
// Settings
override val maxFailureCount: Int = 100
companion object {
val collection: String = "AttachmentDownloadJobCollection"
}
override fun execute() {
TODO("Not yet implemented")
}
} }

View File

@ -1,4 +1,17 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
class AttachmentUploadJob : Job { class AttachmentUploadJob : Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
// Settings
override val maxFailureCount: Int = 20
companion object {
val collection: String = "AttachmentUploadJobCollection"
}
override fun execute() {
TODO("Not yet implemented")
}
} }

View File

@ -1,4 +1,11 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
interface Job { interface Job {
var delegate: JobDelegate?
var id: String?
var failureCount: Int
val maxFailureCount: Int
fun execute()
} }

View File

@ -1,4 +1,7 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
interface JobDelegate { interface JobDelegate {
fun handleJobSucceeded(job: Job)
fun handleJobFailed(job: Job, error: Exception)
fun handleJobFailedPermanently(job: Job, error: Exception)
} }

View File

@ -1,4 +1,89 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
import kotlin.math.min
import kotlin.math.pow
import java.util.Timer
import org.session.libsession.messaging.Configuration
import org.session.libsignal.libsignal.logging.Log
import kotlin.concurrent.schedule
import kotlin.math.roundToLong
class JobQueue : JobDelegate { class JobQueue : JobDelegate {
private var hasResumedPendingJobs = false // Just for debugging
companion object {
val shared: JobQueue by lazy { JobQueue() }
}
fun add(job: Job) {
addWithoutExecuting(job)
job.execute()
}
fun addWithoutExecuting(job: Job) {
job.id = System.currentTimeMillis().toString()
Configuration.shared.storage.persist(job)
job.delegate = this
}
fun resumePendingJobs() {
if (hasResumedPendingJobs) {
Log.d("Loki", "resumePendingJobs() should only be called once.")
return
}
hasResumedPendingJobs = true
val allJobTypes = listOf(AttachmentDownloadJob.collection, AttachmentDownloadJob.collection, MessageReceiveJob.collection, MessageSendJob.collection, NotifyPNServerJob.collection)
allJobTypes.forEach { type ->
val allPendingJobs = Configuration.shared.storage.getAllPendingJobs(type)
allPendingJobs.sortedBy { it.id }.forEach { job ->
Log.i("Jobs", "Resuming pending job of type: ${job::class.simpleName}.")
job.delegate = this
job.execute()
}
}
}
override fun handleJobSucceeded(job: Job) {
Configuration.shared.storage.markJobAsSucceeded(job)
}
override fun handleJobFailed(job: Job, error: Exception) {
job.failureCount += 1
val storage = Configuration.shared.storage
if (storage.isJobCanceled(job)) { return Log.i("Jobs", "${job::class.simpleName} canceled.")}
storage.persist(job)
if (job.failureCount == job.maxFailureCount) {
storage.markJobAsFailed(job)
} else {
val retryInterval = getRetryInterval(job)
Log.i("Jobs", "${job::class.simpleName} failed; scheduling retry (failure count is ${job.failureCount}).")
Timer().schedule(delay = retryInterval) {
Log.i("Jobs", "Retrying ${job::class.simpleName}.")
job.execute()
}
}
}
override fun handleJobFailedPermanently(job: Job, error: Exception) {
job.failureCount += 1
val storage = Configuration.shared.storage
storage.persist(job)
storage.markJobAsFailed(job)
}
private fun getRetryInterval(job: Job): Long {
// Arbitrary backoff factor...
// try 1 delay: 0ms
// try 2 delay: 190ms
// ...
// try 5 delay: 1300ms
// ...
// try 11 delay: 61310ms
val backoffFactor = 1.9
val maxBackoff = (60 * 60 * 1000).toDouble()
return (100 * min(maxBackoff, backoffFactor.pow(job.failureCount))).roundToLong()
}
} }

View File

@ -1,4 +1,17 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
class MessageReceiveJob : Job { class MessageReceiveJob : Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
// Settings
override val maxFailureCount: Int = 10
companion object {
val collection: String = "MessageReceiveJobCollection"
}
override fun execute() {
TODO("Not yet implemented")
}
} }

View File

@ -1,4 +1,17 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
class MessageSendJob : Job { class MessageSendJob : Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
// Settings
override val maxFailureCount: Int = 10
companion object {
val collection: String = "MessageSendJobCollection"
}
override fun execute() {
TODO("Not yet implemented")
}
} }

View File

@ -1,4 +1,57 @@
package org.session.messaging.jobs package org.session.libsession.messaging.jobs
class NotifyPNServerJob : Job { import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.utilities.retryIfNeeded
class NotifyPNServerJob(val message: SnodeMessage) : Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
// Settings
override val maxFailureCount: Int = 20
companion object {
val collection: String = "NotifyPNServerJobCollection"
}
// Running
override fun execute() {
val server = PushNotificationAPI.server
val parameters = mapOf( "data" to message.data, "send_to" to message.recipient )
val url = "${server}/notify"
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
if (code == null || code == 0) {
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: $exception.")
}
}.success {
handleSuccess()
}. fail {
handleFailure(it)
}
}
private fun handleSuccess() {
delegate?.handleJobSucceeded(this)
}
private fun handleFailure(error: Exception) {
delegate?.handleJobFailed(this, error)
}
} }

View File

@ -1,5 +1,12 @@
package org.session.messaging.messages package org.session.libsession.messaging.messages
enum class Destination { sealed class Destination {
class Contact(val publicKey: String) : Destination()
class ClosedGroup(val groupPublicKey: String) : Destination()
class OpenGroup(val channel: Long, val server: String) : Destination()
companion object {
//TODO need to implement the equivalent to TSThread and then implement from(...)
}
} }

View File

@ -1,5 +1,26 @@
package org.session.messaging.messages package org.session.libsession.messaging.messages
open class Message { import org.session.libsignal.service.internal.push.SignalServiceProtos
abstract class Message {
var id: String? = null
var threadID: String? = null
var sentTimestamp: Long? = null
var receivedTimestamp: Long? = null
var recipient: String? = null
var sender: String? = null
var groupPublicKey: String? = null
var openGroupServerMessageID: Long? = null
val ttl: Long = 2 * 24 * 60 * 60 * 1000
// validation
open fun isValid(): Boolean {
sentTimestamp = if (sentTimestamp!! > 0) sentTimestamp else return false
receivedTimestamp = if (receivedTimestamp!! > 0) receivedTimestamp else return false
return sender != null && recipient != null
}
abstract fun toProto(): SignalServiceProtos.Content?
} }

View File

@ -1,4 +1,154 @@
package org.session.messaging.messages.control package org.session.libsession.messaging.messages.control
class ClosedGroupUpdate : ControlMessage() { import com.google.protobuf.ByteString
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey
class ClosedGroupUpdate() : ControlMessage() {
var kind: Kind? = null
// Kind enum
sealed class Kind {
class New(val groupPublicKey: ByteArray, val name: String, val groupPrivateKey: ByteArray, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
class Info(val groupPublicKey: ByteArray, val name: String, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
class SenderKeyRequest(val groupPublicKey: ByteArray) : Kind()
class SenderKey(val groupPublicKey: ByteArray, val senderKey: org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey) : Kind()
}
companion object {
const val TAG = "ClosedGroupUpdate"
fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupUpdate? {
val closedGroupUpdateProto = proto.dataMessage?.closedGroupUpdate ?: return null
val groupPublicKey = closedGroupUpdateProto.groupPublicKey
var kind: Kind
when(closedGroupUpdateProto.type) {
SignalServiceProtos.ClosedGroupUpdate.Type.NEW -> {
val name = closedGroupUpdateProto.name ?: return null
val groupPrivateKey = closedGroupUpdateProto.groupPrivateKey ?: return null
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
kind = Kind.New(
groupPublicKey = groupPublicKey.toByteArray(),
name = name,
groupPrivateKey = groupPrivateKey.toByteArray(),
senderKeys = senderKeys,
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
)
}
SignalServiceProtos.ClosedGroupUpdate.Type.INFO -> {
val name = closedGroupUpdateProto.name ?: return null
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
kind = Kind.Info(
groupPublicKey = groupPublicKey.toByteArray(),
name = name,
senderKeys = senderKeys,
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
)
}
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST -> {
kind = Kind.SenderKeyRequest(groupPublicKey = groupPublicKey.toByteArray())
}
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY -> {
val senderKeyProto = closedGroupUpdateProto.senderKeysList?.first() ?: return null
kind = Kind.SenderKey(
groupPublicKey = groupPublicKey.toByteArray(),
senderKey = ClosedGroupSenderKey.fromProto(senderKeyProto)
)
}
}
return ClosedGroupUpdate(kind)
}
}
// constructor
internal constructor(kind: Kind?) : this() {
this.kind = kind
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
val kind = kind ?: return false
when(kind) {
is Kind.New -> {
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.groupPrivateKey.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
}
is Kind.Info -> {
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
}
is Kind.SenderKeyRequest -> {
return !kind.groupPublicKey.isEmpty()
}
is Kind.SenderKey -> {
return !kind.groupPublicKey.isEmpty()
}
}
}
override fun toProto(): SignalServiceProtos.Content? {
val kind = kind
if (kind == null) {
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
return null
}
try {
val closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdate.Builder = SignalServiceProtos.ClosedGroupUpdate.newBuilder()
when (kind) {
is Kind.New -> {
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.NEW
closedGroupUpdate.name = kind.name
closedGroupUpdate.groupPrivateKey = ByteString.copyFrom(kind.groupPrivateKey)
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
}
is Kind.Info -> {
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.INFO
closedGroupUpdate.name = kind.name
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
}
is Kind.SenderKeyRequest -> {
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST
}
is Kind.SenderKey -> {
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY
closedGroupUpdate.addAllSenderKeys(listOf( kind.senderKey.toProto() ))
}
}
val contentProto = SignalServiceProtos.Content.newBuilder()
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
dataMessageProto.closedGroupUpdate = closedGroupUpdate.build()
contentProto.dataMessage = dataMessageProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
return null
}
return null
}
}
// extension functions to class ClosedGroupSenderKey
private fun ClosedGroupSenderKey.Companion.fromProto(proto: SignalServiceProtos.ClosedGroupUpdate.SenderKey): ClosedGroupSenderKey {
return ClosedGroupSenderKey(chainKey = proto.chainKey.toByteArray(), keyIndex = proto.keyIndex, publicKey = proto.publicKey.toByteArray())
}
private fun ClosedGroupSenderKey.toProto(): SignalServiceProtos.ClosedGroupUpdate.SenderKey {
val proto = SignalServiceProtos.ClosedGroupUpdate.SenderKey.newBuilder()
proto.chainKey = ByteString.copyFrom(chainKey)
proto.keyIndex = keyIndex
proto.publicKey = ByteString.copyFrom(publicKey)
return proto.build()
} }

View File

@ -1,6 +1,6 @@
package org.session.messaging.messages.control package org.session.libsession.messaging.messages.control
import org.session.messaging.messages.Message import org.session.libsession.messaging.messages.Message
open class ControlMessage : Message() { abstract class ControlMessage : Message() {
} }

View File

@ -1,4 +1,51 @@
package org.session.messaging.messages.control package org.session.libsession.messaging.messages.control
class ExpirationTimerUpdate : ControlMessage() { import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class ExpirationTimerUpdate() : ControlMessage() {
var duration: Int? = 0
companion object {
const val TAG = "ExpirationTimerUpdate"
fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? {
val dataMessageProto = proto.dataMessage ?: return null
val isExpirationTimerUpdate = (dataMessageProto.flags and SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0 //TODO validate that 'and' operator equivalent to Swift '&'
if (!isExpirationTimerUpdate) return null
val duration = dataMessageProto.expireTimer
return ExpirationTimerUpdate(duration)
}
}
//constructor
internal constructor(duration: Int) : this() {
this.duration = duration
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
return duration != null
}
override fun toProto(): SignalServiceProtos.Content? {
val duration = duration
if (duration == null) {
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
return null
}
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
dataMessageProto.flags = SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE
dataMessageProto.expireTimer = duration
val contentProto = SignalServiceProtos.Content.newBuilder()
try {
contentProto.dataMessage = dataMessageProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
return null
}
}
} }

View File

@ -1,4 +1,53 @@
package org.session.messaging.messages.control package org.session.libsession.messaging.messages.control
class ReadReceipt : ControlMessage() { import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class ReadReceipt() : ControlMessage() {
var timestamps: LongArray? = null
companion object {
const val TAG = "ReadReceipt"
fun fromProto(proto: SignalServiceProtos.Content): ReadReceipt? {
val receiptProto = proto.receiptMessage ?: return null
if (receiptProto.type != SignalServiceProtos.ReceiptMessage.Type.READ) return null
val timestamps = receiptProto.timestampList
if (timestamps.isEmpty()) return null
return ReadReceipt(timestamps = timestamps.toLongArray())
}
}
//constructor
internal constructor(timestamps: LongArray?) : this() {
this.timestamps = timestamps
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
val timestamps = timestamps ?: return false
if (timestamps.isNotEmpty()) { return true }
return false
}
override fun toProto(): SignalServiceProtos.Content? {
val timestamps = timestamps
if (timestamps == null) {
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
return null
}
val receiptProto = SignalServiceProtos.ReceiptMessage.newBuilder()
receiptProto.type = SignalServiceProtos.ReceiptMessage.Type.READ
receiptProto.addAllTimestamp(timestamps.asIterable())
val contentProto = SignalServiceProtos.Content.newBuilder()
try {
contentProto.receiptMessage = receiptProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
return null
}
}
} }

View File

@ -1,4 +1,75 @@
package org.session.messaging.messages.control package org.session.libsession.messaging.messages.control
class TypingIndicator : ControlMessage() { import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class TypingIndicator() : ControlMessage() {
companion object {
const val TAG = "TypingIndicator"
//val ttl: 30 * 1000 //TODO
fun fromProto(proto: SignalServiceProtos.Content): TypingIndicator? {
val typingIndicatorProto = proto.typingMessage ?: return null
val kind = Kind.fromProto(typingIndicatorProto.action)
return TypingIndicator(kind = kind)
}
}
// Kind enum
enum class Kind {
STARTED,
STOPPED,
;
companion object {
@JvmStatic
fun fromProto(proto: SignalServiceProtos.TypingMessage.Action): Kind =
when (proto) {
SignalServiceProtos.TypingMessage.Action.STARTED -> STARTED
SignalServiceProtos.TypingMessage.Action.STOPPED -> STOPPED
}
}
fun toProto(): SignalServiceProtos.TypingMessage.Action {
when (this) {
STARTED -> return SignalServiceProtos.TypingMessage.Action.STARTED
STOPPED -> return SignalServiceProtos.TypingMessage.Action.STOPPED
}
}
}
var kind: Kind? = null
//constructor
internal constructor(kind: Kind) : this() {
this.kind = kind
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
return kind != null
}
override fun toProto(): SignalServiceProtos.Content? {
val timestamp = sentTimestamp
val kind = kind
if (timestamp == null || kind == null) {
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
return null
}
val typingIndicatorProto = SignalServiceProtos.TypingMessage.newBuilder()
typingIndicatorProto.timestamp = timestamp
typingIndicatorProto.action = kind.toProto()
val contentProto = SignalServiceProtos.Content.newBuilder()
try {
contentProto.typingMessage = typingIndicatorProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
return null
}
}
} }

View File

@ -1,6 +1,35 @@
package org.session.messaging.messages.control.unused package org.session.libsession.messaging.messages.control.unused
import org.session.messaging.messages.control.ControlMessage import com.google.protobuf.ByteString
import org.session.libsession.messaging.messages.control.ControlMessage
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
import java.security.SecureRandom
class NullMessage : ControlMessage() { class NullMessage() : ControlMessage() {
companion object {
const val TAG = "NullMessage"
fun fromProto(proto: SignalServiceProtos.Content): NullMessage? {
if (proto.nullMessage == null) return null
return NullMessage()
}
}
override fun toProto(): SignalServiceProtos.Content? {
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
val sr = SecureRandom()
val paddingSize = sr.nextInt(512)
val padding = ByteArray(paddingSize)
nullMessageProto.padding = ByteString.copyFrom(padding)
val contentProto = SignalServiceProtos.Content.newBuilder()
try {
contentProto.nullMessage = nullMessageProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct null message proto from: $this")
return null
}
}
} }

View File

@ -1,6 +1,83 @@
package org.session.messaging.messages.control.unused package org.session.libsession.messaging.messages.control.unused
import org.session.messaging.messages.control.ControlMessage import com.google.protobuf.ByteString
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.messages.control.ControlMessage
import org.session.libsignal.libsignal.IdentityKey
import org.session.libsignal.libsignal.ecc.DjbECPublicKey
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.libsignal.state.PreKeyBundle
import org.session.libsignal.service.internal.push.SignalServiceProtos
import java.security.SecureRandom
class SessionRequest : ControlMessage() { class SessionRequest() : ControlMessage() {
var preKeyBundle: PreKeyBundle? = null
companion object {
const val TAG = "SessionRequest"
fun fromProto(proto: SignalServiceProtos.Content): SessionRequest? {
if (proto.nullMessage == null) return null
val preKeyBundleProto = proto.preKeyBundleMessage ?: return null
var registrationID: Int = 0
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID() //TODO no implementation for getOrGenerateRegistrationID yet
//TODO just confirm if the above code does the equivalent to swift below:
/*iOS code: Configuration.shared.storage.with { transaction in
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID(using: transaction)
}*/
val preKeyBundle = PreKeyBundle(
registrationID,
1,
preKeyBundleProto.preKeyId,
DjbECPublicKey(preKeyBundleProto.preKey.toByteArray()),
preKeyBundleProto.signedKeyId,
DjbECPublicKey(preKeyBundleProto.signedKey.toByteArray()),
preKeyBundleProto.signature.toByteArray(),
IdentityKey(DjbECPublicKey(preKeyBundleProto.identityKey.toByteArray()))
)
return SessionRequest(preKeyBundle)
}
}
//constructor
internal constructor(preKeyBundle: PreKeyBundle) : this() {
this.preKeyBundle = preKeyBundle
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
return preKeyBundle != null
}
override fun toProto(): SignalServiceProtos.Content? {
val preKeyBundle = preKeyBundle
if (preKeyBundle == null) {
Log.w(TAG, "Couldn't construct session request proto from: $this")
return null
}
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
val sr = SecureRandom()
val paddingSize = sr.nextInt(512)
val padding = ByteArray(paddingSize)
nullMessageProto.padding = ByteString.copyFrom(padding)
val preKeyBundleProto = SignalServiceProtos.PreKeyBundleMessage.newBuilder()
preKeyBundleProto.identityKey = ByteString.copyFrom(preKeyBundle.identityKey.publicKey.serialize())
preKeyBundleProto.deviceId = preKeyBundle.deviceId
preKeyBundleProto.preKeyId = preKeyBundle.preKeyId
preKeyBundleProto.preKey = ByteString.copyFrom(preKeyBundle.preKey.serialize())
preKeyBundleProto.signedKeyId = preKeyBundle.signedPreKeyId
preKeyBundleProto.signedKey = ByteString.copyFrom(preKeyBundle.signedPreKey.serialize())
preKeyBundleProto.signature = ByteString.copyFrom(preKeyBundle.signedPreKeySignature)
val contentProto = SignalServiceProtos.Content.newBuilder()
try {
contentProto.nullMessage = nullMessageProto.build()
contentProto.preKeyBundleMessage = preKeyBundleProto.build()
return contentProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct session request proto from: $this")
return null
}
}
} }

View File

@ -0,0 +1,69 @@
package org.session.libsession.messaging.messages.visible
import android.util.Size
import android.webkit.MimeTypeMap
import org.session.libsignal.service.internal.push.SignalServiceProtos
import java.io.File
class Attachment : VisibleMessageProto<SignalServiceProtos.AttachmentPointer?>() {
var fileName: String? = null
var contentType: String? = null
var key: ByteArray? = null
var digest: ByteArray? = null
var kind: Kind? = null
var caption: String? = null
var size: Size? = null
var sizeInBytes: Int? = 0
var url: String? = null
companion object {
fun fromProto(proto: SignalServiceProtos.AttachmentPointer): Attachment? {
val result = Attachment()
result.fileName = proto.fileName
fun inferContentType(): String {
val fileName = result.fileName ?: return "application/octet-stream" //TODO find equivalent to OWSMimeTypeApplicationOctetStream
val fileExtension = File(fileName).extension
val mimeTypeMap = MimeTypeMap.getSingleton()
return mimeTypeMap.getMimeTypeFromExtension(fileExtension) ?: "application/octet-stream" //TODO check that it's correct
}
result.contentType = proto.contentType ?: inferContentType()
result.key = proto.key.toByteArray()
result.digest = proto.digest.toByteArray()
val kind: Kind
if (proto.hasFlags() && (proto.flags and SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { //TODO validate that 'and' operator = swift '&'
kind = Kind.VOICEMESSAGE
} else {
kind = Kind.GENERIC
}
result.kind = kind
result.caption = if (proto.hasCaption()) proto.caption else null
val size: Size
if (proto.hasWidth() && proto.width > 0 && proto.hasHeight() && proto.height > 0) {
size = Size(proto.width, proto.height)
} else {
size = Size(0,0) //TODO check that it's equivalent to swift: CGSize.zero
}
result.size = size
result.sizeInBytes = if (proto.size > 0) proto.size else null
result. url = proto.url
return result
}
}
enum class Kind {
VOICEMESSAGE,
GENERIC
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
// key and digest can be nil for open group attachments
return (contentType != null && kind != null && size != null && sizeInBytes != null && url != null)
}
override fun toProto(transaction: String): SignalServiceProtos.AttachmentPointer? {
TODO("Not implemented")
}
}

View File

@ -1,4 +1,16 @@
package org.session.messaging.messages.visible package org.session.libsession.messaging.messages.visible
internal class Contact { import org.session.libsignal.service.internal.push.SignalServiceProtos
class Contact : VisibleMessageProto<SignalServiceProtos.DataMessage.Contact?>() {
companion object {
fun fromProto(proto: SignalServiceProtos.Content): Contact? {
TODO("Not yet implemented")
}
}
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Contact? {
TODO("Not yet implemented")
}
} }

View File

@ -1,4 +1,57 @@
package org.session.messaging.messages.visible package org.session.libsession.messaging.messages.visible
internal class LinkPreview { import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class LinkPreview() : VisibleMessageProto<SignalServiceProtos.DataMessage.Preview?>(){
var title: String? = null
var url: String? = null
var attachmentID: String? = null
companion object {
const val TAG = "LinkPreview"
fun fromProto(proto: SignalServiceProtos.DataMessage.Preview): LinkPreview? {
val title = proto.title
val url = proto.url
return LinkPreview(title, url, null)
}
}
//constructor
internal constructor(title: String?, url: String, attachmentID: String?) : this() {
this.title = title
this.url = url
this.attachmentID = attachmentID
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
return (title != null && url != null && attachmentID != null)
}
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Preview? {
val url = url
if (url == null) {
Log.w(TAG, "Couldn't construct link preview proto from: $this")
return null
}
val linkPreviewProto = SignalServiceProtos.DataMessage.Preview.newBuilder()
linkPreviewProto.url = url
title?.let { linkPreviewProto.title = title }
val attachmentID = attachmentID
attachmentID?.let {
//TODO database stuff
}
// Build
try {
return linkPreviewProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct link preview proto from: $this")
return null
}
}
} }

View File

@ -1,4 +1,64 @@
package org.session.messaging.messages.visible package org.session.libsession.messaging.messages.visible
internal class Profile { import com.google.protobuf.ByteString
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class Profile() : VisibleMessageProto<SignalServiceProtos.DataMessage?>() {
var displayName: String? = null
var profileKey: ByteArray? = null
var profilePictureURL: String? = null
companion object {
const val TAG = "Profile"
fun fromProto(proto: SignalServiceProtos.DataMessage): Profile? {
val profileProto = proto.profile ?: return null
val displayName = profileProto.displayName ?: return null
val profileKey = proto.profileKey
val profilePictureURL = profileProto.profilePictureURL
profileKey?.let {
val profilePictureURL = profilePictureURL
profilePictureURL?.let {
return Profile(displayName = displayName, profileKey = profileKey.toByteArray(), profilePictureURL = profilePictureURL)
}
}
return Profile(displayName)
}
}
//constructor
internal constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() {
this.displayName = displayName
this.profileKey = profileKey
this.profilePictureURL = profilePictureURL
}
fun toSSProto(): SignalServiceProtos.DataMessage? {
return this.toProto("")
}
override fun toProto(transaction: String): SignalServiceProtos.DataMessage? {
val displayName = displayName
if (displayName == null) {
Log.w(TAG, "Couldn't construct link preview proto from: $this")
return null
}
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
val profileProto = SignalServiceProtos.LokiUserProfile.newBuilder()
profileProto.displayName = displayName
val profileKey = profileKey
profileKey?.let { dataMessageProto.profileKey = ByteString.copyFrom(profileKey) }
val profilePictureURL = profilePictureURL
profilePictureURL?.let { profileProto.profilePictureURL = profilePictureURL }
// Build
try {
dataMessageProto.profile = profileProto.build()
return dataMessageProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct profile proto from: $this")
return null
}
}
} }

View File

@ -1,4 +1,72 @@
package org.session.messaging.messages.visible package org.session.libsession.messaging.messages.visible
internal class Quote { import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class Quote() : VisibleMessageProto<SignalServiceProtos.DataMessage.Quote?>() {
var timestamp: Long? = 0
var publicKey: String? = null
var text: String? = null
var attachmentID: String? = null
companion object {
const val TAG = "Quote"
fun fromProto(proto: SignalServiceProtos.DataMessage.Quote): Quote? {
val timestamp = proto.id
val publicKey = proto.author
val text = proto.text
return Quote(timestamp, publicKey, text, null)
}
}
//constructor
internal constructor(timestamp: Long, publicKey: String, text: String?, attachmentID: String?) : this() {
this.timestamp = timestamp
this.publicKey = publicKey
this.text = text
this.attachmentID = attachmentID
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
return (timestamp != null && publicKey != null)
}
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Quote? {
val timestamp = timestamp
val publicKey = publicKey
if (timestamp == null || publicKey == null) {
Log.w(TAG, "Couldn't construct quote proto from: $this")
return null
}
val quoteProto = SignalServiceProtos.DataMessage.Quote.newBuilder()
quoteProto.id = timestamp
quoteProto.author = publicKey
text?.let { quoteProto.text = text }
addAttachmentsIfNeeded(quoteProto, transaction)
// Build
try {
return quoteProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct quote proto from: $this")
return null
}
}
private fun addAttachmentsIfNeeded(quoteProto: SignalServiceProtos.DataMessage.Quote.Builder, transaction: String) {
val attachmentID = attachmentID ?: return
//TODO databas stuff
val quotedAttachmentProto = SignalServiceProtos.DataMessage.Quote.QuotedAttachment.newBuilder()
//TODO more database related stuff
//quotedAttachmentProto.contentType =
try {
quoteProto.addAttachments(quotedAttachmentProto.build())
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct quoted attachment proto from: $this")
}
}
} }

View File

@ -1,6 +1,102 @@
package org.session.messaging.messages.visible package org.session.libsession.messaging.messages.visible
import org.session.messaging.messages.Message import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
class VisibleMessage() : VisibleMessageProto<SignalServiceProtos.Content?>() {
var text: String? = null
var attachmentIDs = ArrayList<String>()
var quote: Quote? = null
var linkPreview: LinkPreview? = null
var contact: Contact? = null
var profile: Profile? = null
companion object {
const val TAG = "BaseVisibleMessage"
fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? {
val dataMessage = proto.dataMessage ?: return null
val result = VisibleMessage()
result.text = dataMessage.body
// Attachments are handled in MessageReceiver
val quoteProto = dataMessage.quote
quoteProto?.let {
val quote = Quote.fromProto(quoteProto)
quote?.let { result.quote = quote }
}
val linkPreviewProto = dataMessage.previewList.first()
linkPreviewProto?.let {
val linkPreview = LinkPreview.fromProto(linkPreviewProto)
linkPreview?.let { result.linkPreview = linkPreview }
}
// TODO Contact
val profile = Profile.fromProto(dataMessage)
profile?.let { result.profile = profile }
return result
}
}
// validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
if (attachmentIDs.isNotEmpty()) return true
val text = text?.trim() ?: return false
if (text.isNotEmpty()) return true
return false
}
override fun toProto(transaction: String): SignalServiceProtos.Content? {
val proto = SignalServiceProtos.Content.newBuilder()
var attachmentIDs = this.attachmentIDs
val dataMessage: SignalServiceProtos.DataMessage.Builder
// Profile
val profile = profile
val profileProto = profile?.toSSProto()
if (profileProto != null) {
dataMessage = profileProto.toBuilder()
} else {
dataMessage = SignalServiceProtos.DataMessage.newBuilder()
}
// Text
text?.let { dataMessage.body = text }
// Quote
val quotedAttachmentID = quote?.attachmentID
quotedAttachmentID?.let {
val index = attachmentIDs.indexOf(quotedAttachmentID)
if (index >= 0) { attachmentIDs.removeAt(index) }
}
val quote = quote
quote?.let {
val quoteProto = quote.toProto(transaction)
if (quoteProto != null) dataMessage.quote = quoteProto
}
//Link preview
val linkPreviewAttachmentID = linkPreview?.attachmentID
linkPreviewAttachmentID?.let {
val index = attachmentIDs.indexOf(quotedAttachmentID)
if (index >= 0) { attachmentIDs.removeAt(index) }
}
val linkPreview = linkPreview
linkPreview?.let {
val linkPreviewProto = linkPreview.toProto(transaction)
linkPreviewProto?.let {
dataMessage.addAllPreview(listOf(linkPreviewProto))
}
}
//Attachments
// TODO I'm blocking on that one...
//swift: let attachments = attachmentIDs.compactMap { TSAttachmentStream.fetch(uniqueId: $0, transaction: transaction) }
// TODO Contact
// Build
try {
proto.dataMessage = dataMessage.build()
return proto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct visible message proto from: $this")
return null
}
}
class VisibleMessage : Message() {
} }

View File

@ -0,0 +1,15 @@
package org.session.libsession.messaging.messages.visible
import org.session.libsession.messaging.messages.Message
import org.session.libsignal.service.internal.push.SignalServiceProtos
abstract class VisibleMessageProto<out T: com.google.protobuf.MessageOrBuilder?> : Message() {
abstract fun toProto(transaction: String): T
final override fun toProto(): SignalServiceProtos.Content? {
//we don't need to implement this method in subclasses
//TODO it just needs an equivalent to swift: preconditionFailure("Use toProto(using:) if that exists...
TODO("Not implemented")
}
}

View File

@ -1,4 +0,0 @@
package org.session.messaging.messages.visible.attachments
internal class Attachment {
}

View File

@ -0,0 +1,37 @@
package org.session.libsession.messaging.opengroups
import org.session.libsignal.service.internal.util.JsonUtil
public data class OpenGroup(
public val channel: Long,
private val serverURL: String,
public val displayName: String,
public val isDeletable: Boolean
) {
public val server get() = serverURL.toLowerCase()
public val id get() = getId(channel, server)
companion object {
@JvmStatic fun getId(channel: Long, server: String): String {
return "$server.$channel"
}
@JvmStatic fun fromJSON(jsonAsString: String): OpenGroup? {
try {
val json = JsonUtil.fromJson(jsonAsString)
val channel = json.get("channel").asLong()
val server = json.get("server").asText().toLowerCase()
val displayName = json.get("displayName").asText()
val isDeletable = json.get("isDeletable").asBoolean()
return OpenGroup(channel, server, displayName, isDeletable)
} catch (e: Exception) {
return null
}
}
}
public fun toJSON(): Map<String, Any> {
return mapOf( "channel" to channel, "server" to server, "displayName" to displayName, "isDeletable" to isDeletable )
}
}

View File

@ -0,0 +1,381 @@
package org.session.libsession.messaging.opengroups
import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.then
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.messaging.fileserver.FileServerAPI
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.internal.util.Hex
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.utilities.DownloadUtilities
import org.session.libsignal.service.loki.utilities.createContext
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.*
object OpenGroupAPI: DotNetAPI() {
private val moderators: HashMap<String, HashMap<Long, Set<String>>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs)
val sharedContext = Kovenant.createContext("LokiPublicChatAPISharedContext")
// region Settings
private val fallbackBatchCount = 64
private val maxRetryCount = 8
// endregion
// region Convenience
private val channelInfoType = "net.patter-app.settings"
private val attachmentType = "net.app.core.oembed"
@JvmStatic
public val openGroupMessageType = "network.loki.messenger.publicChat"
@JvmStatic
public val profilePictureType = "network.loki.messenger.avatar"
fun getDefaultChats(): List<OpenGroup> {
return listOf() // Don't auto-join any open groups right now
}
public fun isUserModerator(hexEncodedPublicKey: String, channel: Long, server: String): Boolean {
if (moderators[server] != null && moderators[server]!![channel] != null) {
return moderators[server]!![channel]!!.contains(hexEncodedPublicKey)
}
return false
}
// endregion
// region Public API
public fun getMessages(channel: Long, server: String): Promise<List<OpenGroupMessage>, Exception> {
Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.")
val storage = Configuration.shared.storage
val parameters = mutableMapOf<String, Any>( "include_annotations" to 1 )
val lastMessageServerID = storage.getLastMessageServerID(channel, server)
if (lastMessageServerID != null) {
parameters["since_id"] = lastMessageServerID
} else {
parameters["count"] = fallbackBatchCount
parameters["include_deleted"] = 0
}
return execute(HTTPVerb.GET, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json ->
try {
val data = json["data"] as List<Map<*, *>>
val messages = data.mapNotNull { message ->
try {
val isDeleted = message["is_deleted"] as? Boolean ?: false
if (isDeleted) { return@mapNotNull null }
// Ignore messages without annotations
if (message["annotations"] == null) { return@mapNotNull null }
val annotation = (message["annotations"] as List<Map<*, *>>).find {
((it["type"] as? String ?: "") == openGroupMessageType) && it["value"] != null
} ?: return@mapNotNull null
val value = annotation["value"] as Map<*, *>
val serverID = message["id"] as? Long ?: (message["id"] as? Int)?.toLong() ?: (message["id"] as String).toLong()
val user = message["user"] as Map<*, *>
val publicKey = user["username"] as String
val displayName = user["name"] as? String ?: "Anonymous"
var profilePicture: OpenGroupMessage.ProfilePicture? = null
if (user["annotations"] != null) {
val profilePictureAnnotation = (user["annotations"] as List<Map< *, *>>).find {
((it["type"] as? String ?: "") == profilePictureType) && it["value"] != null
}
val profilePictureAnnotationValue = profilePictureAnnotation?.get("value") as? Map<*, *>
if (profilePictureAnnotationValue != null && profilePictureAnnotationValue["profileKey"] != null && profilePictureAnnotationValue["url"] != null) {
try {
val profileKey = Base64.decode(profilePictureAnnotationValue["profileKey"] as String)
val url = profilePictureAnnotationValue["url"] as String
profilePicture = OpenGroupMessage.ProfilePicture(profileKey, url)
} catch (e: Exception) {}
}
}
@Suppress("NAME_SHADOWING") val body = message["text"] as String
val timestamp = value["timestamp"] as? Long ?: (value["timestamp"] as? Int)?.toLong() ?: (value["timestamp"] as String).toLong()
var quote: OpenGroupMessage.Quote? = null
if (value["quote"] != null) {
val replyTo = message["reply_to"] as? Long ?: (message["reply_to"] as? Int)?.toLong() ?: (message["reply_to"] as String).toLong()
val quoteAnnotation = value["quote"] as? Map<*, *>
val quoteTimestamp = quoteAnnotation?.get("id") as? Long ?: (quoteAnnotation?.get("id") as? Int)?.toLong() ?: (quoteAnnotation?.get("id") as? String)?.toLong() ?: 0L
val author = quoteAnnotation?.get("author") as? String
val text = quoteAnnotation?.get("text") as? String
quote = if (quoteTimestamp > 0L && author != null && text != null) OpenGroupMessage.Quote(quoteTimestamp, author, text, replyTo) else null
}
val attachmentsAsJSON = (message["annotations"] as List<Map<*, *>>).filter {
((it["type"] as? String ?: "") == attachmentType) && it["value"] != null
}
val attachments = attachmentsAsJSON.mapNotNull { it["value"] as? Map<*, *> }.mapNotNull { attachmentAsJSON ->
try {
val kindAsString = attachmentAsJSON["lokiType"] as String
val kind = OpenGroupMessage.Attachment.Kind.values().first { it.rawValue == kindAsString }
val id = attachmentAsJSON["id"] as? Long ?: (attachmentAsJSON["id"] as? Int)?.toLong() ?: (attachmentAsJSON["id"] as String).toLong()
val contentType = attachmentAsJSON["contentType"] as String
val size = attachmentAsJSON["size"] as? Int ?: (attachmentAsJSON["size"] as? Long)?.toInt() ?: (attachmentAsJSON["size"] as String).toInt()
val fileName = attachmentAsJSON["fileName"] as String
val flags = 0
val url = attachmentAsJSON["url"] as String
val caption = attachmentAsJSON["caption"] as? String
val linkPreviewURL = attachmentAsJSON["linkPreviewUrl"] as? String
val linkPreviewTitle = attachmentAsJSON["linkPreviewTitle"] as? String
if (kind == OpenGroupMessage.Attachment.Kind.LinkPreview && (linkPreviewURL == null || linkPreviewTitle == null)) {
null
} else {
OpenGroupMessage.Attachment(kind, server, id, contentType, size, fileName, flags, 0, 0, caption, url, linkPreviewURL, linkPreviewTitle)
}
} catch (e: Exception) {
Log.d("Loki","Couldn't parse attachment due to error: $e.")
null
}
}
// Set the last message server ID here to avoid the situation where a message doesn't have a valid signature and this function is called over and over
@Suppress("NAME_SHADOWING") val lastMessageServerID = storage.getLastMessageServerID(channel, server)
if (serverID > lastMessageServerID ?: 0) { storage.setLastMessageServerID(channel, server, serverID) }
val hexEncodedSignature = value["sig"] as String
val signatureVersion = value["sigver"] as? Long ?: (value["sigver"] as? Int)?.toLong() ?: (value["sigver"] as String).toLong()
val signature = OpenGroupMessage.Signature(Hex.fromStringCondensed(hexEncodedSignature), signatureVersion)
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
format.timeZone = TimeZone.getTimeZone("GMT")
val dateAsString = message["created_at"] as String
val serverTimestamp = format.parse(dateAsString).time
// Verify the message
val groupMessage = OpenGroupMessage(serverID, publicKey, displayName, body, timestamp, openGroupMessageType, quote, attachments, profilePicture, signature, serverTimestamp)
if (groupMessage.hasValidSignature()) groupMessage else null
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server from: ${JsonUtil.toJson(message)}. Exception: ${exception.message}")
return@mapNotNull null
}
}.sortedBy { it.serverTimestamp }
messages
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse messages for open group with ID: $channel on server: $server.")
throw exception
}
}
}
public fun getDeletedMessageServerIDs(channel: Long, server: String): Promise<List<Long>, Exception> {
Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.")
val storage = Configuration.shared.storage
val parameters = mutableMapOf<String, Any>()
val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
if (lastDeletionServerID != null) {
parameters["since_id"] = lastDeletionServerID
} else {
parameters["count"] = fallbackBatchCount
}
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/deletes", parameters = parameters).then(sharedContext) { json ->
try {
val deletedMessageServerIDs = (json["data"] as List<Map<*, *>>).mapNotNull { deletion ->
try {
val serverID = deletion["id"] as? Long ?: (deletion["id"] as? Int)?.toLong() ?: (deletion["id"] as String).toLong()
val messageServerID = deletion["message_id"] as? Long ?: (deletion["message_id"] as? Int)?.toLong() ?: (deletion["message_id"] as String).toLong()
@Suppress("NAME_SHADOWING") val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
if (serverID > (lastDeletionServerID ?: 0)) { storage.setLastDeletionServerID(channel, server, serverID) }
messageServerID
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse deleted message for open group with ID: $channel on server: $server. Exception: ${exception.message}")
return@mapNotNull null
}
}
deletedMessageServerIDs
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse deleted messages for open group with ID: $channel on server: $server.")
throw exception
}
}
}
public fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise<OpenGroupMessage, Exception> {
val deferred = deferred<OpenGroupMessage, Exception>()
val storage = Configuration.shared.storage
val userKeyPair = storage.getUserKeyPair() ?: throw Error.Generic
val userDisplayName = storage.getUserDisplayName() ?: throw Error.Generic
Thread {
val signedMessage = message.sign(userKeyPair.privateKey.serialize())
if (signedMessage == null) {
deferred.reject(Error.SigningFailed)
} else {
retryIfNeeded(maxRetryCount) {
Log.d("Loki", "Sending message to open group with ID: $channel on server: $server.")
val parameters = signedMessage.toJSON()
execute(HTTPVerb.POST, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json ->
try {
val data = json["data"] as Map<*, *>
val serverID = (data["id"] as? Long) ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as String).toLong()
val text = data["text"] as String
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
format.timeZone = TimeZone.getTimeZone("GMT")
val dateAsString = data["created_at"] as String
val timestamp = format.parse(dateAsString).time
@Suppress("NAME_SHADOWING") val message = OpenGroupMessage(serverID, userKeyPair.hexEncodedPublicKey, userDisplayName, text, timestamp, openGroupMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp)
message
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server.")
throw exception
}
}
}.success {
deferred.resolve(it)
}.fail {
deferred.reject(it)
}
}
}.start()
return deferred.promise
}
public fun deleteMessage(messageServerID: Long, channel: Long, server: String, isSentByUser: Boolean): Promise<Long, Exception> {
return retryIfNeeded(maxRetryCount) {
val isModerationRequest = !isSentByUser
Log.d("Loki", "Deleting message with ID: $messageServerID from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
val endpoint = if (isSentByUser) "channels/$channel/messages/$messageServerID" else "loki/v1/moderation/message/$messageServerID"
execute(HTTPVerb.DELETE, server, endpoint, isJSONRequired = false).then {
Log.d("Loki", "Deleted message with ID: $messageServerID from open group with ID: $channel on server: $server.")
messageServerID
}
}
}
public fun deleteMessages(messageServerIDs: List<Long>, channel: Long, server: String, isSentByUser: Boolean): Promise<List<Long>, Exception> {
return retryIfNeeded(maxRetryCount) {
val isModerationRequest = !isSentByUser
val parameters = mapOf( "ids" to messageServerIDs.joinToString(",") )
Log.d("Loki", "Deleting messages with IDs: ${messageServerIDs.joinToString()} from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
val endpoint = if (isSentByUser) "loki/v1/messages" else "loki/v1/moderation/messages"
execute(HTTPVerb.DELETE, server, endpoint, parameters = parameters, isJSONRequired = false).then { json ->
Log.d("Loki", "Deleted messages with IDs: $messageServerIDs from open group with ID: $channel on server: $server.")
messageServerIDs
}
}
}
public fun getModerators(channel: Long, server: String): Promise<Set<String>, Exception> {
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then(sharedContext) { json ->
try {
@Suppress("UNCHECKED_CAST") val moderators = json["moderators"] as? List<String>
val moderatorsAsSet = moderators.orEmpty().toSet()
if (this.moderators[server] != null) {
this.moderators[server]!![channel] = moderatorsAsSet
} else {
this.moderators[server] = hashMapOf( channel to moderatorsAsSet )
}
moderatorsAsSet
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse moderators for open group with ID: $channel on server: $server.")
throw exception
}
}
}
public fun getChannelInfo(channel: Long, server: String): Promise<OpenGroupInfo, Exception> {
return retryIfNeeded(maxRetryCount) {
val parameters = mapOf( "include_annotations" to 1 )
execute(HTTPVerb.GET, server, "/channels/$channel", parameters = parameters).then(sharedContext) { json ->
try {
val data = json["data"] as Map<*, *>
val annotations = data["annotations"] as List<Map<*, *>>
val annotation = annotations.find { (it["type"] as? String ?: "") == channelInfoType } ?: throw Error.ParsingFailed
val info = annotation["value"] as Map<*, *>
val displayName = info["name"] as String
val countInfo = data["counts"] as Map<*, *>
val memberCount = countInfo["subscribers"] as? Int ?: (countInfo["subscribers"] as? Long)?.toInt() ?: (countInfo["subscribers"] as String).toInt()
val profilePictureURL = info["avatar"] as String
val publicChatInfo = OpenGroupInfo(displayName, profilePictureURL, memberCount)
Configuration.shared.storage.setUserCount(channel, server, memberCount)
publicChatInfo
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse info for open group with ID: $channel on server: $server.")
throw exception
}
}
}
}
public fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) {
val storage = Configuration.shared.storage
storage.setUserCount(channel, server, info.memberCount)
storage.updateTitle(groupID, info.displayName)
// Download and update profile picture if needed
val oldProfilePictureURL = storage.getOpenGroupProfilePictureURL(channel, server)
if (isForcedUpdate || oldProfilePictureURL != info.profilePictureURL) {
val profilePictureAsByteArray = downloadOpenGroupProfilePicture(server, info.profilePictureURL) ?: return
storage.updateProfilePicture(groupID, profilePictureAsByteArray)
storage.setOpenGroupProfilePictureURL(channel, server, info.profilePictureURL)
}
}
public fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? {
val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}"
Log.d("Loki", "Downloading open group profile picture from \"$url\".")
val outputStream = ByteArrayOutputStream()
try {
DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null)
Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"")
return outputStream.toByteArray()
} catch (e: Exception) {
Log.d("Loki", "Failed to download open group profile picture from \"$url\" due to error: $e.")
return null
} finally {
outputStream.close()
}
}
public fun join(channel: Long, server: String): Promise<Unit, Exception> {
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then {
Log.d("Loki", "Joined channel with ID: $channel on server: $server.")
}
}
}
public fun leave(channel: Long, server: String): Promise<Unit, Exception> {
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then {
Log.d("Loki", "Left channel with ID: $channel on server: $server.")
}
}
}
public fun getDisplayNames(publicKeys: Set<String>, server: String): Promise<Map<String, String>, Exception> {
return getUserProfiles(publicKeys, server, false).map(sharedContext) { json ->
val mapping = mutableMapOf<String, String>()
for (user in json) {
if (user["username"] != null) {
val publicKey = user["username"] as String
val displayName = user["name"] as? String ?: "Anonymous"
mapping[publicKey] = displayName
}
}
mapping
}
}
public fun setDisplayName(newDisplayName: String?, server: String): Promise<Unit, Exception> {
Log.d("Loki", "Updating display name on server: $server.")
val parameters = mapOf( "name" to (newDisplayName ?: "") )
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit }
}
public fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise<Unit, Exception> {
return setProfilePicture(server, Base64.encodeBytes(profileKey), url)
}
public fun setProfilePicture(server: String, profileKey: String, url: String?): Promise<Unit, Exception> {
Log.d("Loki", "Updating profile picture on server: $server.")
val value = when (url) {
null -> null
else -> mapOf( "profileKey" to profileKey, "url" to url )
}
// TODO: This may actually completely replace the annotations, have to double check it
return setSelfAnnotation(server, profilePictureType, value).map { Unit }.fail {
Log.d("Loki", "Failed to update profile picture due to error: $it.")
}
}
// endregion
}

View File

@ -0,0 +1,7 @@
package org.session.libsession.messaging.opengroups
public data class OpenGroupInfo (
public val displayName: String,
public val profilePictureURL: String,
public val memberCount: Int
)

View File

@ -0,0 +1,242 @@
package org.session.libsession.messaging.opengroups
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.Hex
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
import org.whispersystems.curve25519.Curve25519
public data class OpenGroupMessage(
public val serverID: Long?,
public val senderPublicKey: String,
public val displayName: String,
public val body: String,
public val timestamp: Long,
public val type: String,
public val quote: Quote?,
public val attachments: List<Attachment>,
public val profilePicture: ProfilePicture?,
public val signature: Signature?,
public val serverTimestamp: Long,
) {
// region Settings
companion object {
fun from(message: VisibleMessage, server: String): OpenGroupMessage? {
val storage = Configuration.shared.storage
val userPublicKey = storage.getUserPublicKey() ?: return null
// Validation
if (!message.isValid()) { return null } // Should be valid at this point
// Quote
val quote: Quote? = {
val quote = message.quote
if (quote != null && quote.isValid()) {
val quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey)
Quote(quote.timestamp!!, quote.publicKey!!, quote.text!!, quotedMessageServerID)
} else {
null
}
}()
// Message
val displayname = storage.getUserDisplayName() ?: "Anonymous"
val body = message.text ?: message.sentTimestamp.toString() // The back-end doesn't accept messages without a body so we use this as a workaround
val result = OpenGroupMessage(null, userPublicKey, displayname, body, message.sentTimestamp!!, OpenGroupAPI.openGroupMessageType, quote, mutableListOf(), null, null, 0)
// Link preview
val linkPreview = message.linkPreview
linkPreview?.let {
if (!linkPreview.isValid()) { return@let }
val attachment = linkPreview.getImage() ?: return@let
val openGroupLinkPreview = Attachment(
Attachment.Kind.LinkPreview,
server,
attachment.getId(),
attachment.getContentType(),
attachment.getSize(),
attachment.getFileName(),
attachment.getFlags(),
attachment.getWidth(),
attachment.getHeight(),
attachment.getCaption(),
attachment.getUrl(),
linkPreview.url,
linkPreview.title)
result.attachments.add(openGroupLinkPreview)
}
// Attachments
val attachments = message.getAttachemnts().forEach {
val attachement = Attachment(
Attachment.Kind.Attachment,
server,
it.getId(),
it.getContentType(),
it.getSize(),
it.getFileName(),
it.getFlags(),
it.getWidth(),
it.getHeight(),
it.getCaption(),
it.getUrl(),
linkPreview.getUrl(),
linkPreview.getTitle())
result.attachments.add(attachement)
}
// Return
return result
}
private val curve = Curve25519.getInstance(Curve25519.BEST)
private val signatureVersion: Long = 1
private val attachmentType = "net.app.core.oembed"
}
// endregion
// region Types
public data class ProfilePicture(
public val profileKey: ByteArray,
public val url: String,
)
public data class Quote(
public val quotedMessageTimestamp: Long,
public val quoteePublicKey: String,
public val quotedMessageBody: String,
public val quotedMessageServerID: Long? = null,
)
public data class Signature(
public val data: ByteArray,
public val version: Long,
)
public data class Attachment(
public val kind: Kind,
public val server: String,
public val serverID: Long,
public val contentType: String,
public val size: Int,
public val fileName: String,
public val flags: Int,
public val width: Int,
public val height: Int,
public val caption: String?,
public val url: String,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
public val linkPreviewURL: String?,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
public val linkPreviewTitle: String?,
) {
public val dotNetAPIType = when {
contentType.startsWith("image") -> "photo"
contentType.startsWith("video") -> "video"
contentType.startsWith("audio") -> "audio"
else -> "other"
}
public enum class Kind(val rawValue: String) {
Attachment("attachment"), LinkPreview("preview")
}
}
// endregion
// region Initialization
constructor(hexEncodedPublicKey: String, displayName: String, body: String, timestamp: Long, type: String, quote: Quote?, attachments: List<Attachment>)
: this(null, hexEncodedPublicKey, displayName, body, timestamp, type, quote, attachments, null, null, 0)
// endregion
// region Crypto
internal fun sign(privateKey: ByteArray): OpenGroupMessage? {
val data = getValidationData(signatureVersion)
if (data == null) {
Log.d("Loki", "Failed to sign public chat message.")
return null
}
try {
val signatureData = curve.calculateSignature(privateKey, data)
val signature = Signature(signatureData, signatureVersion)
return copy(signature = signature)
} catch (e: Exception) {
Log.d("Loki", "Failed to sign public chat message due to error: ${e.message}.")
return null
}
}
internal fun hasValidSignature(): Boolean {
if (signature == null) { return false }
val data = getValidationData(signature.version) ?: return false
val publicKey = Hex.fromStringCondensed(senderPublicKey.removing05PrefixIfNeeded())
try {
return curve.verifySignature(publicKey, data, signature.data)
} catch (e: Exception) {
Log.d("Loki", "Failed to verify public chat message due to error: ${e.message}.")
return false
}
}
// endregion
// region Parsing
internal fun toJSON(): Map<String, Any> {
val value = mutableMapOf<String, Any>("timestamp" to timestamp)
if (quote != null) {
value["quote"] = mapOf("id" to quote.quotedMessageTimestamp, "author" to quote.quoteePublicKey, "text" to quote.quotedMessageBody)
}
if (signature != null) {
value["sig"] = Hex.toStringCondensed(signature.data)
value["sigver"] = signature.version
}
val annotation = mapOf("type" to type, "value" to value)
val annotations = mutableListOf(annotation)
attachments.forEach { attachment ->
val attachmentValue = mutableMapOf(
// Fields required by the .NET API
"version" to 1,
"type" to attachment.dotNetAPIType,
// Custom fields
"lokiType" to attachment.kind.rawValue,
"server" to attachment.server,
"id" to attachment.serverID,
"contentType" to attachment.contentType,
"size" to attachment.size,
"fileName" to attachment.fileName,
"flags" to attachment.flags,
"width" to attachment.width,
"height" to attachment.height,
"url" to attachment.url
)
if (attachment.caption != null) { attachmentValue["caption"] = attachment.caption }
if (attachment.linkPreviewURL != null) { attachmentValue["linkPreviewUrl"] = attachment.linkPreviewURL }
if (attachment.linkPreviewTitle != null) { attachmentValue["linkPreviewTitle"] = attachment.linkPreviewTitle }
val attachmentAnnotation = mapOf("type" to attachmentType, "value" to attachmentValue)
annotations.add(attachmentAnnotation)
}
val result = mutableMapOf("text" to body, "annotations" to annotations)
if (quote?.quotedMessageServerID != null) {
result["reply_to"] = quote.quotedMessageServerID
}
return result
}
// endregion
// region Convenience
private fun getValidationData(signatureVersion: Long): ByteArray? {
var string = "${body.trim()}$timestamp"
if (quote != null) {
string += "${quote.quotedMessageTimestamp}${quote.quoteePublicKey}${quote.quotedMessageBody.trim()}"
if (quote.quotedMessageServerID != null) {
string += "${quote.quotedMessageServerID}"
}
}
string += attachments.sortedBy { it.serverID }.map { it.serverID }.joinToString("")
string += "$signatureVersion"
try {
return string.toByteArray(Charsets.UTF_8)
} catch (exception: Exception) {
return null
}
}
// endregion
}

View File

@ -1,4 +1,29 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
object MessageReceiver { object MessageReceiver {
internal sealed class Error(val description: String) : Exception() {
object InvalidMessage: Error("Invalid message.")
object UnknownMessage: Error("Unknown message type.")
object UnknownEnvelopeType: Error("Unknown envelope type.")
object NoUserPublicKey: Error("Couldn't find user key pair.")
object NoData: Error("Received an empty envelope.")
object SenderBlocked: Error("Received a message from a blocked user.")
object NoThread: Error("Couldn't find thread for message.")
object SelfSend: Error("Message addressed at self.")
object ParsingFailed : Error("Couldn't parse ciphertext message.")
// Shared sender keys
object InvalidGroupPublicKey: Error("Invalid group public key.")
object NoGroupPrivateKey: Error("Missing group private key.")
object SharedSecretGenerationFailed: Error("Couldn't generate a shared secret.")
internal val isRetryable: Boolean = when (this) {
is InvalidMessage -> false
is UnknownMessage -> false
is UnknownEnvelopeType -> false
is NoData -> false
is SenderBlocked -> false
is SelfSend -> false
else -> true
}
}
} }

View File

@ -1,4 +1,58 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
import org.session.libsession.messaging.Configuration
import org.session.libsignal.service.api.push.SignalServiceAddress
import org.session.libsignal.service.loki.crypto.LokiServiceCipher
import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error
import org.session.libsession.utilities.AESGCM
import org.whispersystems.curve25519.Curve25519
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
import org.session.libsignal.libsignal.util.Pair
import org.session.libsignal.service.api.messages.SignalServiceEnvelope
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
import org.session.libsignal.service.loki.utilities.toHexString
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object MessageReceiverDecryption { object MessageReceiverDecryption {
internal fun decryptWithSignalProtocol(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
val storage = Configuration.shared.signalStorage
val certificateValidator = Configuration.shared.certificateValidator
val sskDatabase = Configuration.shared.sskDatabase
val sessionResetImp = Configuration.shared.sessionResetImp
val data = envelope.content
if (data.count() == 0) { throw Error.NoData }
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
val cipher = LokiServiceCipher(SignalServiceAddress(userPublicKey), storage, sskDatabase, sessionResetImp, certificateValidator)
val result = cipher.decrypt(envelope)
}
internal fun decryptWithSharedSenderKeys(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
// 1. ) Check preconditions
val groupPublicKey = envelope.source
if (!Configuration.shared.storage.isClosedGroup(groupPublicKey)) { throw Error.InvalidGroupPublicKey }
val data = envelope.content
if (data.count() == 0) { throw Error.NoData }
val groupPrivateKey = Configuration.shared.storage.getClosedGroupPrivateKey(groupPublicKey) ?: throw Error.NoGroupPrivateKey
// 2. ) Parse the wrapper
val wrapper = SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.parseFrom(data)
val ivAndCiphertext = wrapper.ciphertext.toByteArray()
val ephemeralPublicKey = wrapper.ephemeralPublicKey.toByteArray()
// 3. ) Decrypt the data inside
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(ephemeralPublicKey, groupPrivateKey.serialize())
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
val closedGroupCiphertextMessageAsData = AESGCM.decrypt(ivAndCiphertext, symmetricKey)
// 4. ) Parse the closed group ciphertext message
val closedGroupCiphertextMessage = ClosedGroupCiphertextMessage.from(closedGroupCiphertextMessageAsData) ?: throw Error.ParsingFailed
val senderPublicKey = closedGroupCiphertextMessage.senderPublicKey.toHexString()
if (senderPublicKey == Configuration.shared.storage.getUserPublicKey()) { throw Error.SelfSend }
// 5. ) Use the info inside the closed group ciphertext message to decrypt the actual message content
val plaintext = SharedSenderKeysImplementation.shared.decrypt(closedGroupCiphertextMessage.ivAndCiphertext, groupPublicKey, senderPublicKey, closedGroupCiphertextMessage.keyIndex)
// 6. ) Return
return Pair(plaintext, senderPublicKey)
}
} }

View File

@ -1,4 +1,4 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
interface MessageReceiverDelegate { interface MessageReceiverDelegate {
} }

View File

@ -1,4 +1,198 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.jobs.NotifyPNServerJob
import org.session.libsession.messaging.opengroups.OpenGroupAPI
import org.session.libsession.messaging.opengroups.OpenGroupMessage
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.snode.RawResponsePromise
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeMessage
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
object MessageSender { object MessageSender {
// Error
internal sealed class Error(val description: String) : Exception() {
object InvalidMessage : Error("Invalid message.")
object ProtoConversionFailed : Error("Couldn't convert message to proto.")
object ProofOfWorkCalculationFailed : Error("Proof of work calculation failed.")
object NoUserPublicKey : Error("Couldn't find user key pair.")
// Closed groups
object NoThread : Error("Couldn't find a thread associated with the given group public key.")
object NoPrivateKey : Error("Couldn't find a private key associated with the given group public key.")
object InvalidClosedGroupUpdate : Error("Invalid group update.")
internal val isRetryable: Boolean = when (this) {
is InvalidMessage -> false
is ProtoConversionFailed -> false
is ProofOfWorkCalculationFailed -> false
is InvalidClosedGroupUpdate -> false
else -> true
}
}
// Convenience
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
if (destination is Destination.OpenGroup) {
return sendToOpenGroupDestination(destination, message)
}
return sendToSnodeDestination(destination, message)
}
// One-on-One Chats & Closed Groups
fun sendToSnodeDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
val promise = deferred.promise
val storage = Configuration.shared.storage
val preconditionFailure = Exception("Destination should not be open groups!")
var snodeMessage: SnodeMessage? = null
message.sentTimestamp ?: run { message.sentTimestamp = System.currentTimeMillis() } /* Visible messages will already have their sent timestamp set */
message.sender = storage.getUserPublicKey()
try {
when (destination) {
is Destination.Contact -> message.recipient = destination.publicKey
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
is Destination.OpenGroup -> throw preconditionFailure
}
// Validate the message
if (!message.isValid()) { throw Error.InvalidMessage }
// Convert it to protobuf
val proto = message.toProto() ?: throw Error.ProtoConversionFailed
// Serialize the protobuf
val plaintext = proto.toByteArray()
// Encrypt the serialized protobuf
val ciphertext: ByteArray
when (destination) {
is Destination.Contact -> ciphertext = MessageSenderEncryption.encryptWithSignalProtocol(plaintext, message, destination.publicKey)
is Destination.ClosedGroup -> ciphertext = MessageSenderEncryption.encryptWithSharedSenderKeys(plaintext, destination.groupPublicKey)
is Destination.OpenGroup -> throw preconditionFailure
}
// Wrap the result
val kind: SignalServiceProtos.Envelope.Type
val senderPublicKey: String
when (destination) {
is Destination.Contact -> {
kind = SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER
senderPublicKey = ""
}
is Destination.ClosedGroup -> {
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT
senderPublicKey = destination.groupPublicKey
}
is Destination.OpenGroup -> throw preconditionFailure
}
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
// Calculate proof of work
val recipient = message.recipient!!
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
val timestamp = System.currentTimeMillis()
val nonce = ProofOfWork.calculate(base64EncodedData, recipient, timestamp, message.ttl.toInt()) ?: throw Error.ProofOfWorkCalculationFailed
// Send the result
snodeMessage = SnodeMessage(recipient, base64EncodedData, message.ttl, timestamp, nonce)
SnodeAPI.sendMessage(snodeMessage).success { promises: Set<RawResponsePromise> ->
var isSuccess = false
val promiseCount = promises.size
var errorCount = 0
promises.forEach { promise: RawResponsePromise ->
promise.success {
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
isSuccess = true
deferred.resolve(Unit)
}
promise.fail {
errorCount += 1
if (errorCount != promiseCount) { return@fail } // Only error out if all promises failed
deferred.reject(it)
}
}
}.fail {
Log.d("Loki", "Couldn't send message due to error: $it.")
deferred.reject(it)
}
} catch (exception: Exception) {
deferred.reject(exception)
}
// Handle completion
promise.success {
handleSuccessfulMessageSend(message)
if (message is VisibleMessage && snodeMessage != null) {
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
JobQueue.shared.add(notifyPNServerJob)
}
}
promise.fail {
handleFailedMessageSend(message, it)
}
return promise
}
// Open Groups
fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
val promise = deferred.promise
val storage = Configuration.shared.storage
val preconditionFailure = Exception("Destination should not be contacts or closed groups!")
message.sentTimestamp = System.currentTimeMillis()
message.sender = storage.getUserPublicKey()
try {
val server: String
val channel: Long
when (destination) {
is Destination.Contact -> throw preconditionFailure
is Destination.ClosedGroup -> throw preconditionFailure
is Destination.OpenGroup -> {
message.recipient = "${destination.server}.${destination.channel}"
server = destination.server
channel = destination.channel
}
}
// Validate the message
if (message !is VisibleMessage || !message.isValid()) {
throw Error.InvalidMessage
}
// Convert the message to an open group message
val openGroupMessage = OpenGroupMessage.from(message, server) ?: throw Error.InvalidMessage
// Send the result
OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success {
message.openGroupServerMessageID = it.serverID
deferred.resolve(Unit)
}.fail {
deferred.reject(it)
}
} catch (exception: Exception) {
deferred.reject(exception)
}
// Handle completion
promise.success {
handleSuccessfulMessageSend(message)
}
promise.fail {
handleFailedMessageSend(message, it)
}
return deferred.promise
}
// Result Handling
fun handleSuccessfulMessageSend(message: Message) {
}
fun handleFailedMessageSend(message: Message, error: Exception) {
}
} }

View File

@ -1,4 +1,4 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
interface MessageSenderDelegate { interface MessageSenderDelegate {
} }

View File

@ -1,4 +1,52 @@
package org.session.messaging.sending_receiving package org.session.libsession.messaging.sending_receiving
import com.google.protobuf.ByteString
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
import org.session.libsession.messaging.utilities.UnidentifiedAccessUtil
import org.session.libsession.utilities.AESGCM
import org.session.libsignal.libsignal.SignalProtocolAddress
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
import org.session.libsignal.libsignal.util.Hex
import org.session.libsignal.libsignal.util.guava.Optional
import org.session.libsignal.service.api.crypto.SignalServiceCipher
import org.session.libsignal.service.api.push.SignalServiceAddress
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
object MessageSenderEncryption { object MessageSenderEncryption {
internal fun encryptWithSignalProtocol(plaintext: ByteArray, message: Message, recipientPublicKey: String): ByteArray{
val storage = Configuration.shared.signalStorage
val sskDatabase = Configuration.shared.sskDatabase
val sessionResetImp = Configuration.shared.sessionResetImp
val localAddress = SignalServiceAddress(recipientPublicKey)
val certificateValidator = Configuration.shared.certificateValidator
val cipher = SignalServiceCipher(localAddress, storage, sskDatabase, sessionResetImp, certificateValidator)
val signalProtocolAddress = SignalProtocolAddress(recipientPublicKey, 1)
val unidentifiedAccessPair = UnidentifiedAccessUtil.getAccessFor(recipientPublicKey)
val unidentifiedAccess = if (unidentifiedAccessPair != null) unidentifiedAccessPair.targetUnidentifiedAccess else Optional.absent()
val encryptedMessage = cipher.encrypt(signalProtocolAddress, unidentifiedAccess, plaintext)
return Base64.decode(encryptedMessage.content)
}
internal fun encryptWithSharedSenderKeys(plaintext: ByteArray, groupPublicKey: String): ByteArray {
// 1. ) Encrypt the data with the user's sender key
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
val ciphertextAndKeyIndex = SharedSenderKeysImplementation.shared.encrypt(plaintext, groupPublicKey, userPublicKey)
val ivAndCiphertext = ciphertextAndKeyIndex.first
val keyIndex = ciphertextAndKeyIndex.second
val encryptedMessage = ClosedGroupCiphertextMessage(ivAndCiphertext, Hex.fromStringCondensed(userPublicKey), keyIndex);
// 2. ) Encrypt the result for the group's public key to hide the sender public key and key index
val intermediate = AESGCM.encrypt(encryptedMessage.serialize(), groupPublicKey.removing05PrefixIfNeeded())
// 3. ) Wrap the result
return SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.newBuilder()
.setCiphertext(ByteString.copyFrom(intermediate.ciphertext))
.setEphemeralPublicKey(ByteString.copyFrom(intermediate.ephemeralPublicKey))
.build().toByteArray()
}
} }

View File

@ -1,2 +0,0 @@
package org.session.messaging.sending_receiving

View File

@ -0,0 +1,2 @@
package org.session.libsession.messaging.sending_receiving.notifications

View File

@ -0,0 +1,101 @@
package org.session.libsession.messaging.sending_receiving.notifications
import android.content.Context
import nl.komponents.kovenant.functional.map
import okhttp3.*
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import java.io.IOException
object PushNotificationAPI {
val server = "https://live.apns.getsession.org"
val serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
private val maxRetryCount = 4
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
enum class ClosedGroupOperation {
Subscribe, Unsubscribe;
val rawValue: String
get() {
return when (this) {
Subscribe -> "subscribe_closed_group"
Unsubscribe -> "unsubscribe_closed_group"
}
}
}
fun unregister(token: String, context: Context) {
val parameters = mapOf( "token" to token )
val url = "$server/unregister"
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
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"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
}
}
// Unsubscribe from all closed groups
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
allClosedGroupPublicKeys.forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
}
}
fun register(token: String, publicKey: String, context: Context, force: Boolean) {
val oldToken = TextSecurePreferences.getFCMToken(context)
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
val url = "$server/register"
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
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"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
}
}
// Subscribe to all closed groups
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
allClosedGroupPublicKeys.forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
}
}
fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
if (!TextSecurePreferences.isUsingFCM(context)) { return }
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
val url = "$server/${operation.rawValue}"
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
if (code == null || code == 0) {
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["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,268 @@
package org.session.libsession.messaging.utilities
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.then
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.Configuration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.messaging.fileserver.FileServerAPI
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.libsignal.loki.DiffieHellman
import org.session.libsignal.service.api.crypto.ProfileCipherOutputStream
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
import org.session.libsignal.service.api.push.exceptions.PushNetworkException
import org.session.libsignal.service.api.util.StreamDetails
import org.session.libsignal.service.internal.push.ProfileAvatarData
import org.session.libsignal.service.internal.push.PushAttachmentData
import org.session.libsignal.service.internal.push.http.DigestingRequestBody
import org.session.libsignal.service.internal.push.http.ProfileCipherOutputStreamFactory
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.internal.util.Hex
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.api.utilities.HTTP
import org.session.libsignal.service.loki.utilities.*
import java.util.*
/**
* Base class that provides utilities for .NET based APIs.
*/
open class DotNetAPI {
internal enum class HTTPVerb { GET, PUT, POST, DELETE, PATCH }
// Error
internal sealed class Error(val description: String) : Exception() {
object Generic : Error("An error occurred.")
object InvalidURL : Error("Invalid URL.")
object ParsingFailed : Error("Invalid file server response.")
object SigningFailed : Error("Couldn't sign message.")
object EncryptionFailed : Error("Couldn't encrypt file.")
object DecryptionFailed : Error("Couldn't decrypt file.")
object MaxFileSizeExceeded : Error("Maximum file size exceeded.")
object TokenExpired: Error("Token expired.") // Session Android
}
companion object {
private val authTokenRequestCache = hashMapOf<String, Promise<String, Exception>>()
}
public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)
public fun getAuthToken(server: String): Promise<String, Exception> {
val storage = Configuration.shared.storage
val token = storage.getAuthToken(server)
if (token != null) { return Promise.of(token) }
// Avoid multiple token requests to the server by caching
var promise = authTokenRequestCache[server]
if (promise == null) {
promise = requestNewAuthToken(server).bind { submitAuthToken(it, server) }.then { newToken ->
storage.setAuthToken(server, newToken)
newToken
}.always {
authTokenRequestCache.remove(server)
}
authTokenRequestCache[server] = promise
}
return promise
}
private fun requestNewAuthToken(server: String): Promise<String, Exception> {
Log.d("Loki", "Requesting auth token for server: $server.")
val userKeyPair = Configuration.shared.storage.getUserKeyPair() ?: throw Error.Generic
val parameters: Map<String, Any> = mapOf( "pubKey" to userKeyPair.hexEncodedPublicKey )
return execute(HTTPVerb.GET, server, "loki/v1/get_challenge", false, parameters).map(SnodeAPI.sharedContext) { json ->
try {
val base64EncodedChallenge = json["cipherText64"] as String
val challenge = Base64.decode(base64EncodedChallenge)
val base64EncodedServerPublicKey = json["serverPubKey64"] as String
var serverPublicKey = Base64.decode(base64EncodedServerPublicKey)
// Discard the "05" prefix if needed
if (serverPublicKey.count() == 33) {
val hexEncodedServerPublicKey = Hex.toStringCondensed(serverPublicKey)
serverPublicKey = Hex.fromStringCondensed(hexEncodedServerPublicKey.removing05PrefixIfNeeded())
}
// The challenge is prefixed by the 16 bit IV
val tokenAsData = DiffieHellman.decrypt(challenge, serverPublicKey, userKeyPair.privateKey.serialize())
val token = tokenAsData.toString(Charsets.UTF_8)
token
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse auth token for server: $server.")
throw exception
}
}
}
private fun submitAuthToken(token: String, server: String): Promise<String, Exception> {
Log.d("Loki", "Submitting auth token for server: $server.")
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.Generic
val parameters = mapOf( "pubKey" to userPublicKey, "token" to token )
return execute(HTTPVerb.POST, server, "loki/v1/submit_challenge", false, parameters, isJSONRequired = false).map { token }
}
internal fun execute(verb: HTTPVerb, server: String, endpoint: String, isAuthRequired: Boolean = true, parameters: Map<String, Any> = mapOf(), isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
fun execute(token: String?): Promise<Map<*, *>, Exception> {
val sanitizedEndpoint = endpoint.removePrefix("/")
var url = "$server/$sanitizedEndpoint"
if (verb == HTTPVerb.GET || verb == HTTPVerb.DELETE) {
val queryParameters = parameters.map { "${it.key}=${it.value}" }.joinToString("&")
if (queryParameters.isNotEmpty()) { url += "?$queryParameters" }
}
var request = Request.Builder().url(url)
if (isAuthRequired) {
if (token == null) { throw IllegalStateException() }
request = request.header("Authorization", "Bearer $token")
}
when (verb) {
HTTPVerb.GET -> request = request.get()
HTTPVerb.DELETE -> request = request.delete()
else -> {
val parametersAsJSON = JsonUtil.toJson(parameters)
val body = RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
when (verb) {
HTTPVerb.PUT -> request = request.put(body)
HTTPVerb.POST -> request = request.post(body)
HTTPVerb.PATCH -> request = request.patch(body)
else -> throw IllegalStateException()
}
}
}
val serverPublicKeyPromise = if (server == FileServerAPI.shared.server) Promise.of(FileServerAPI.fileServerPublicKey)
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(server)
return serverPublicKeyPromise.bind { serverPublicKey ->
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, isJSONRequired = isJSONRequired).recover { exception ->
if (exception is HTTP.HTTPRequestFailedException) {
val statusCode = exception.statusCode
if (statusCode == 401 || statusCode == 403) {
Configuration.shared.storage.setAuthToken(server, null)
throw Error.TokenExpired
}
}
throw exception
}
}
}
return if (isAuthRequired) {
getAuthToken(server).bind { execute(it) }
} else {
execute(null)
}
}
internal fun getUserProfiles(publicKeys: Set<String>, server: String, includeAnnotations: Boolean): Promise<List<Map<*, *>>, Exception> {
val parameters = mapOf( "include_user_annotations" to includeAnnotations.toInt(), "ids" to publicKeys.joinToString { "@$it" } )
return execute(HTTPVerb.GET, server, "users", parameters = parameters).map { json ->
val data = json["data"] as? List<Map<*, *>>
if (data == null) {
Log.d("Loki", "Couldn't parse user profiles for: $publicKeys from: $json.")
throw Error.ParsingFailed
}
data!! // For some reason the compiler can't infer that this can't be null at this point
}
}
internal fun setSelfAnnotation(server: String, type: String, newValue: Any?): Promise<Map<*, *>, Exception> {
val annotation = mutableMapOf<String, Any>( "type" to type )
if (newValue != null) { annotation["value"] = newValue }
val parameters = mapOf( "annotations" to listOf( annotation ) )
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters)
}
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult {
// This function mimics what Signal does in PushServiceSocket
val contentType = "application/octet-stream"
val file = DigestingRequestBody(attachment.data, attachment.outputStreamFactory, contentType, attachment.dataSize, attachment.listener)
Log.d("Loki", "File size: ${attachment.dataSize} bytes.")
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", "network.loki")
.addFormDataPart("Content-Type", contentType)
.addFormDataPart("content", UUID.randomUUID().toString(), file)
.build()
val request = Request.Builder().url("$server/files").post(body)
return upload(server, request) { json -> // Retrying is handled by AttachmentUploadJob
val data = json["data"] as? Map<*, *>
if (data == null) {
Log.d("Loki", "Couldn't parse attachment from: $json.")
throw Error.ParsingFailed
}
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
val url = data["url"] as? String
if (id == null || url == null || url.isEmpty()) {
Log.d("Loki", "Couldn't parse upload from: $json.")
throw Error.ParsingFailed
}
UploadResult(id, url, file.transmittedDigest)
}.get()
}
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
fun uploadProfilePicture(server: String, key: ByteArray, profilePicture: StreamDetails, setLastProfilePictureUpload: () -> Unit): UploadResult {
val profilePictureUploadData = ProfileAvatarData(profilePicture.stream, ProfileCipherOutputStream.getCiphertextLength(profilePicture.length), profilePicture.contentType, ProfileCipherOutputStreamFactory(key))
val file = DigestingRequestBody(profilePictureUploadData.data, profilePictureUploadData.outputStreamFactory,
profilePictureUploadData.contentType, profilePictureUploadData.dataLength, null)
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", "network.loki")
.addFormDataPart("Content-Type", "application/octet-stream")
.addFormDataPart("content", UUID.randomUUID().toString(), file)
.build()
val request = Request.Builder().url("$server/files").post(body)
return retryIfNeeded(4) {
upload(server, request) { json ->
val data = json["data"] as? Map<*, *>
if (data == null) {
Log.d("Loki", "Couldn't parse profile picture from: $json.")
throw Error.ParsingFailed
}
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
val url = data["url"] as? String
if (id == null || url == null || url.isEmpty()) {
Log.d("Loki", "Couldn't parse profile picture from: $json.")
throw Error.ParsingFailed
}
setLastProfilePictureUpload()
UploadResult(id, url, file.transmittedDigest)
}
}.get()
}
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
private fun upload(server: String, request: Request.Builder, parse: (Map<*, *>) -> UploadResult): Promise<UploadResult, Exception> {
val promise: Promise<Map<*, *>, Exception>
if (server == FileServerAPI.shared.server) {
request.addHeader("Authorization", "Bearer loki")
// Uploads to the Loki File Server shouldn't include any personally identifiable information, so use a dummy auth token
promise = OnionRequestAPI.sendOnionRequest(request.build(), FileServerAPI.shared.server, FileServerAPI.fileServerPublicKey)
} else {
promise = FileServerAPI.shared.getPublicKeyForOpenGroupServer(server).bind { openGroupServerPublicKey ->
getAuthToken(server).bind { token ->
request.addHeader("Authorization", "Bearer $token")
OnionRequestAPI.sendOnionRequest(request.build(), server, openGroupServerPublicKey)
}
}
}
return promise.map { json ->
parse(json)
}.recover { exception ->
if (exception is HTTP.HTTPRequestFailedException) {
val statusCode = exception.statusCode
if (statusCode == 401 || statusCode == 403) {
Configuration.shared.storage.setAuthToken(server, null)
}
throw NonSuccessfulResponseCodeException("Request returned with status code ${exception.statusCode}.")
}
throw PushNetworkException(exception)
}
}
}
private fun Boolean.toInt(): Int { return if (this) 1 else 0 }

View File

@ -0,0 +1,85 @@
package org.session.libsession.messaging.utilities
import com.google.protobuf.ByteString
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketMessage
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketRequestMessage
import java.security.SecureRandom
object MessageWrapper {
// region Types
sealed class Error(val description: String) : Exception() {
object FailedToWrapData : Error("Failed to wrap data.")
object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.")
object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.")
object FailedToUnwrapData : Error("Failed to unwrap data.")
}
// endregion
// region Wrapping
/**
* Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application.
*/
fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray {
try {
val envelope = createEnvelope(type, timestamp, senderPublicKey, content)
val webSocketMessage = createWebSocketMessage(envelope)
return webSocketMessage.toByteArray()
} catch (e: Exception) {
throw if (e is Error) { e } else { Error.FailedToWrapData }
}
}
private fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope {
try {
val builder = Envelope.newBuilder()
builder.type = type
builder.timestamp = timestamp
builder.source = senderPublicKey
builder.sourceDevice = 1
builder.content = ByteString.copyFrom(content)
return builder.build()
} catch (e: Exception) {
Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.")
throw Error.FailedToWrapMessageInEnvelope
}
}
private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage {
try {
val requestBuilder = WebSocketRequestMessage.newBuilder()
requestBuilder.verb = "PUT"
requestBuilder.path = "/api/v1/message"
requestBuilder.id = SecureRandom.getInstance("SHA1PRNG").nextLong()
requestBuilder.body = envelope.toByteString()
val messageBuilder = WebSocketMessage.newBuilder()
messageBuilder.request = requestBuilder.build()
messageBuilder.type = WebSocketMessage.Type.REQUEST
return messageBuilder.build()
} catch (e: Exception) {
Log.d("Loki", "Failed to wrap envelope in web socket message: ${e.message}.")
throw Error.FailedToWrapEnvelopeInWebSocketMessage
}
}
// endregion
// region Unwrapping
/**
* `data` shouldn't be base 64 encoded.
*/
fun unwrap(data: ByteArray): Envelope {
try {
val webSocketMessage = WebSocketMessage.parseFrom(data)
val envelopeAsData = webSocketMessage.request.body
return Envelope.parseFrom(envelopeAsData)
} catch (e: Exception) {
Log.d("Loki", "Failed to unwrap data: ${e.message}.")
throw Error.FailedToUnwrapData
}
}
// endregion
}

View File

@ -0,0 +1,60 @@
package org.session.libsession.messaging.utilities
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
import com.goterl.lazycode.lazysodium.SodiumAndroid
import org.session.libsession.messaging.Configuration
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.metadata.SignalProtos
import org.session.libsignal.metadata.certificate.InvalidCertificateException
import org.session.libsignal.service.api.crypto.UnidentifiedAccess
import org.session.libsignal.service.api.crypto.UnidentifiedAccessPair
object UnidentifiedAccessUtil {
private val TAG = UnidentifiedAccessUtil::class.simpleName
private val sodium = LazySodiumAndroid(SodiumAndroid())
fun getAccessFor(recipientPublicKey: String): UnidentifiedAccessPair? {
try {
val theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipientPublicKey)
val ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey()
val ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate()
Log.i(TAG, "Their access key present? " + (theirUnidentifiedAccessKey != null) +
" | Our access key present? " + (ourUnidentifiedAccessKey != null) +
" | Our certificate present? " + (ourUnidentifiedAccessCertificate != null))
if (theirUnidentifiedAccessKey != null && ourUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) {
return UnidentifiedAccessPair(UnidentifiedAccess(theirUnidentifiedAccessKey, ourUnidentifiedAccessCertificate),
UnidentifiedAccess(ourUnidentifiedAccessKey, ourUnidentifiedAccessCertificate))
}
return null
} catch (e: InvalidCertificateException) {
Log.w(TAG, e)
return null
}
}
private fun getTargetUnidentifiedAccessKey(recipientPublicKey: String): ByteArray? {
val theirProfileKey = Configuration.shared.storage.getProfileKeyForRecipient(recipientPublicKey) ?: return sodium.randomBytesBuf(16)
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
}
private fun getSelfUnidentifiedAccessKey(): ByteArray? {
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
if (userPublicKey != null) {
return sodium.randomBytesBuf(16)
}
return null
}
private fun getUnidentifiedAccessCertificate(): ByteArray? {
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
if (userPublicKey != null) {
val certificate = SignalProtos.SenderCertificate.newBuilder().setSender(userPublicKey).setSenderDevice(1).build()
return certificate.toByteArray()
}
return null
}
}

View File

@ -0,0 +1,14 @@
package org.session.libsession.snode
import org.session.libsignal.service.loki.utilities.Broadcaster
class Configuration(val storage: SnodeStorageProtocol, val broadcaster: Broadcaster) {
companion object {
lateinit var shared: Configuration
fun configure(storage: SnodeStorageProtocol, broadcaster: Broadcaster) {
if (Companion::shared.isInitialized) { return }
shared = Configuration(storage, broadcaster)
}
}
}

View File

@ -0,0 +1,464 @@
package org.session.libsession.snode
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.utilities.AESGCM
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsignal.service.loki.api.*
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.session.libsignal.service.loki.api.utilities.*
import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsession.utilities.getBodyForOnionRequest
import org.session.libsession.utilities.getHeadersForOnionRequest
import org.session.libsignal.service.loki.utilities.*
private typealias Path = List<Snode>
/**
* See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
*/
public object OnionRequestAPI {
private val pathFailureCount = mutableMapOf<Path, Int>()
private val snodeFailureCount = mutableMapOf<Snode, Int>()
public var guardSnodes = setOf<Snode>()
public var paths: List<Path> // Not a set to ensure we consistently show the same path to the user
get() = SnodeAPI.database.getOnionRequestPaths()
set(newValue) {
if (newValue.isEmpty()) {
SnodeAPI.database.clearOnionRequestPaths()
} else {
SnodeAPI.database.setOnionRequestPaths(newValue)
}
}
// region Settings
/**
* The number of snodes (including the guard snode) in a path.
*/
private val pathSize = 3
/**
* The number of times a path can fail before it's replaced.
*/
private val pathFailureThreshold = 2
/**
* The number of times a snode can fail before it's replaced.
*/
private val snodeFailureThreshold = 2
/**
* The number of paths to maintain.
*/
public val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
/**
* The number of guard snodes required to maintain `targetPathCount` paths.
*/
private val targetGuardSnodeCount
get() = targetPathCount // One per path
// endregion
class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>)
: Exception("HTTP request failed at destination with status code $statusCode.")
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
private data class OnionBuildingResult(
internal val guardSnode: Snode,
internal val finalEncryptionResult: EncryptionResult,
internal val destinationSymmetricKey: ByteArray
)
internal sealed class Destination {
class Snode(val snode: org.session.libsession.snode.Snode) : Destination()
class Server(val host: String, val target: String, val x25519PublicKey: String) : Destination()
}
// region Private API
/**
* Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
*/
private fun testSnode(snode: Snode): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
Thread { // 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)
val version = json["version"] as? String
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@Thread }
if (version >= "2.0.7") {
deferred.resolve(Unit)
} else {
val message = "Unsupported snode version: $version."
Log.d("Loki", message)
deferred.reject(Exception(message))
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}.start()
return deferred.promise
}
/**
* Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not
* enough (reliable) snodes are available.
*/
private fun getGuardSnodes(reusableGuardSnodes: List<Snode>): Promise<Set<Snode>, Exception> {
if (guardSnodes.count() >= targetGuardSnodeCount) {
return Promise.of(guardSnodes)
} else {
Log.d("Loki", "Populating guard snode cache.")
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
var unusedSnodes = SnodeAPI.snodePool.minus(reusableGuardSnodes)
val reusableGuardSnodeCount = reusableGuardSnodes.count()
if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() }
fun getGuardSnode(): Promise<Snode, Exception> {
val candidate = unusedSnodes.getRandomElementOrNull()
?: return Promise.ofFail(InsufficientSnodesException())
unusedSnodes = unusedSnodes.minus(candidate)
Log.d("Loki", "Testing guard snode: $candidate.")
// Loop until a reliable guard snode is found
val deferred = deferred<Snode, Exception>()
testSnode(candidate).success {
deferred.resolve(candidate)
}.fail {
getGuardSnode().success {
deferred.resolve(candidate)
}.fail { exception ->
if (exception is InsufficientSnodesException) {
deferred.reject(exception)
}
}
}
return deferred.promise
}
val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() }
all(promises).map(SnodeAPI.sharedContext) { guardSnodes ->
val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet()
OnionRequestAPI.guardSnodes = guardSnodesAsSet
guardSnodesAsSet
}
}
}
}
/**
* Builds and returns `targetPathCount` paths. The returned promise errors out if not
* enough (reliable) snodes are available.
*/
private fun buildPaths(reusablePaths: List<Path>): Promise<List<Path>, Exception> {
Log.d("Loki", "Building onion request paths.")
SnodeAPI.broadcaster.broadcast("buildingPaths")
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
val reusableGuardSnodes = reusablePaths.map { it[0] }
getGuardSnodes(reusableGuardSnodes).map(SnodeAPI.sharedContext) { guardSnodes ->
var unusedSnodes = SnodeAPI.snodePool.minus(guardSnodes).minus(reusablePaths.flatten())
val reusableGuardSnodeCount = reusableGuardSnodes.count()
val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
// Don't test path snodes as this would reveal the user's IP to them
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).map {
val pathSnode = unusedSnodes.getRandomElement()
unusedSnodes = unusedSnodes.minus(pathSnode)
pathSnode
}
Log.d("Loki", "Built new onion request path: $result.")
result
}
}.map { paths ->
OnionRequestAPI.paths = paths + reusablePaths
SnodeAPI.broadcaster.broadcast("pathsBuilt")
paths
}
}
}
/**
* Returns a `Path` to be used for building an onion request. Builds new paths as needed.
*/
private fun getPath(snodeToExclude: Snode?): Promise<Path, Exception> {
if (pathSize < 1) { throw Exception("Can't build path of size zero.") }
val paths = this.paths
val guardSnodes = mutableSetOf<Snode>()
if (paths.isNotEmpty()) {
guardSnodes.add(paths[0][0])
if (paths.count() >= 2) {
guardSnodes.add(paths[1][0])
}
}
OnionRequestAPI.guardSnodes = guardSnodes
fun getPath(paths: List<Path>): Path {
if (snodeToExclude != null) {
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
} else {
return 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
return Promise.of(getPath(paths))
} else {
return buildPaths(paths).map(SnodeAPI.sharedContext) { newPaths ->
getPath(newPaths)
}
}
} else {
return buildPaths(listOf()).map(SnodeAPI.sharedContext) { newPaths ->
getPath(newPaths)
}
}
}
private fun dropGuardSnode(snode: Snode) {
guardSnodes = guardSnodes.filter { it != snode }.toSet()
}
private fun dropSnode(snode: Snode) {
// We repair the path here because we can do it sync. In the case where we drop a whole
// path we leave the re-building up to getPath() because re-building the path in that case
// is async.
snodeFailureCount[snode] = 0
val oldPaths = paths.toMutableList()
val pathIndex = oldPaths.indexOfFirst { it.contains(snode) }
if (pathIndex == -1) { return }
val path = oldPaths[pathIndex].toMutableList()
val snodeIndex = path.indexOf(snode)
if (snodeIndex == -1) { return }
path.removeAt(snodeIndex)
val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten())
if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() }
path.add(unusedSnodes.getRandomElement())
// Don't test the new snode as this would reveal the user's IP
oldPaths.removeAt(pathIndex)
val newPaths = oldPaths + listOf( path )
paths = newPaths
}
private fun dropPath(path: Path) {
pathFailureCount[path] = 0
val paths = OnionRequestAPI.paths.toMutableList()
val pathIndex = paths.indexOf(path)
if (pathIndex == -1) { return }
paths.removeAt(pathIndex)
OnionRequestAPI.paths = paths
}
/**
* Builds an onion around `payload` and returns the result.
*/
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): 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
val snodeToExclude = when (destination) {
is Destination.Snode -> destination.snode
is Destination.Server -> null
}
return getPath(snodeToExclude).bind(SnodeAPI.sharedContext) { path ->
guardSnode = path.first()
// Encrypt in reverse order, i.e. the destination first
OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind(SnodeAPI.sharedContext) { 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)
} else {
val lhs = Destination.Snode(path.last())
path = path.dropLast(1)
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind(SnodeAPI.sharedContext) { r ->
encryptionResult = r
rhs = lhs
addLayer()
}
}
}
addLayer()
}
}.map(SnodeAPI.sharedContext) { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) }
}
/**
* Sends an onion request to `destination`. Builds new paths as needed.
*/
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>, isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
val deferred = deferred<Map<*, *>, Exception>()
lateinit var guardSnode: Snode
buildOnionForDestination(payload, destination).success { result ->
guardSnode = result.guardSnode
val url = "${guardSnode.address}:${guardSnode.port}/onion_req/v2"
val finalEncryptionResult = result.finalEncryptionResult
val onion = finalEncryptionResult.ciphertext
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(
"ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString()
)
val body: ByteArray
try {
body = OnionRequestEncryption.encode(onion, parameters)
} catch (exception: Exception) {
return@success deferred.reject(exception)
}
val destinationSymmetricKey = result.destinationSymmetricKey
Thread {
try {
val json = HTTP.execute(HTTP.Verb.POST, url, body)
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@Thread 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"] 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)
return@Thread 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
if (!isJSONRequired) {
body = mapOf( "result" to bodyAsString )
} else {
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
}
}
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
return@Thread deferred.reject(exception)
}
deferred.resolve(body)
} else {
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, json)
return@Thread deferred.reject(exception)
}
deferred.resolve(json)
}
} catch (exception: Exception) {
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
}
} catch (exception: Exception) {
deferred.reject(exception)
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}.start()
}.fail { exception ->
deferred.reject(exception)
}
val promise = deferred.promise
promise.fail { exception ->
val path = paths.firstOrNull { it.contains(guardSnode) }
if (exception is HTTP.HTTPRequestFailedException) {
fun handleUnspecificError() {
if (path == null) { return }
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0
pathFailureCount += 1
if (pathFailureCount >= pathFailureThreshold) {
dropGuardSnode(guardSnode)
path.forEach { snode ->
@Suppress("ThrowableNotThrown")
SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw
}
dropPath(path)
} else {
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
}
}
val json = exception.json
val message = json?.get("result") as? String
val prefix = "Next node not found: "
if (message != null && message.startsWith(prefix)) {
val ed25519PublicKey = message.substringAfter(prefix)
val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey }
if (snode != null) {
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0
snodeFailureCount += 1
if (snodeFailureCount >= snodeFailureThreshold) {
@Suppress("ThrowableNotThrown")
SnodeAPI.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw
try {
dropSnode(snode)
} catch (exception: Exception) {
handleUnspecificError()
}
} else {
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
}
} else {
handleUnspecificError()
}
} else if (message == "Loki Server error") {
// Do nothing
} else {
handleUnspecificError()
}
}
}
return promise
}
// endregion
// region Internal API
/**
* Sends an onion request to `snode`. Builds new paths as needed.
*/
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String): Promise<Map<*, *>, Exception> {
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
if (httpRequestFailedException != null) {
val error = SnodeAPI.handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
if (error != null) { throw error }
}
throw exception
}
}
/**
* Sends an onion request to `server`. Builds new paths as needed.
*
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
*/
public fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc", isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
val headers = request.getHeadersForOnionRequest()
val url = request.url()
val urlAsString = url.toString()
val host = url.host()
val endpoint = when {
server.count() < urlAsString.count() -> urlAsString.substringAfter("$server/")
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)
return sendOnionRequest(destination, payload, isJSONRequired).recover { exception ->
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.")
throw exception
}
}
// endregion
}

View File

@ -0,0 +1,94 @@
package org.session.libsession.snode
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsignal.service.internal.util.JsonUtil
import org.session.libsession.utilities.AESGCM.EncryptionResult
import org.session.libsession.utilities.AESGCM
import org.session.libsignal.service.loki.utilities.toHexString
import java.nio.Buffer
import java.nio.ByteBuffer
import java.nio.ByteOrder
object OnionRequestEncryption {
internal fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray {
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
val jsonAsData = JsonUtil.toJson(json).toByteArray()
val ciphertextSize = ciphertext.size
val buffer = ByteBuffer.allocate(Int.SIZE_BYTES)
buffer.order(ByteOrder.LITTLE_ENDIAN)
buffer.putInt(ciphertextSize)
val ciphertextSizeAsData = ByteArray(buffer.capacity())
// Casting here avoids an issue where this gets compiled down to incorrect byte code. See
// https://github.com/eclipse/jetty.project/issues/3244 for more info
(buffer as Buffer).position(0)
buffer.get(ciphertextSizeAsData)
return ciphertextSizeAsData + ciphertext + jsonAsData
}
/**
* 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> {
val deferred = deferred<EncryptionResult, Exception>()
Thread {
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)
}
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}.start()
return deferred.promise
}
/**
* 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> {
val deferred = deferred<EncryptionResult, Exception>()
Thread {
try {
val payload: MutableMap<String, Any>
when (rhs) {
is OnionRequestAPI.Destination.Snode -> {
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
}
is OnionRequestAPI.Destination.Server -> {
payload = mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST" )
}
}
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
val x25519PublicKey: String
when (lhs) {
is OnionRequestAPI.Destination.Snode -> {
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
}
is OnionRequestAPI.Destination.Server -> {
x25519PublicKey = lhs.x25519PublicKey
}
}
val plaintext = encode(previousEncryptionResult.ciphertext, payload)
val result = AESGCM.encrypt(plaintext, x25519PublicKey)
deferred.resolve(result)
} catch (exception: Exception) {
deferred.reject(exception)
}
}.start()
return deferred.promise
}
}

View File

@ -0,0 +1,34 @@
package org.session.libsession.snode
public class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
val ip: String get() = address.removePrefix("https://")
internal enum class Method(val rawValue: String) {
/**
* Only supported by snode targets.
*/
GetSwarm("get_snodes_for_pubkey"),
/**
* Only supported by snode targets.
*/
GetMessages("retrieve"),
SendMessage("store")
}
data class KeySet(val ed25519Key: String, val x25519Key: String)
override fun equals(other: Any?): Boolean {
return if (other is Snode) {
address == other.address && port == other.port
} else {
false
}
}
override fun hashCode(): Int {
return address.hashCode() xor port.hashCode()
}
override fun toString(): String { return "$address:$port" }
}

View File

@ -0,0 +1,370 @@
@file:Suppress("NAME_SHADOWING")
package org.session.libsession.snode
import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.task
import org.session.libsession.snode.utilities.getRandomElement
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.internal.util.Base64
import org.session.libsignal.service.loki.api.MessageWrapper
import org.session.libsignal.service.loki.api.utilities.HTTP
import org.session.libsignal.service.loki.utilities.createContext
import org.session.libsignal.service.loki.utilities.prettifiedDescription
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
import java.security.SecureRandom
object SnodeAPI {
val database = Configuration.shared.storage
val broadcaster = Configuration.shared.broadcaster
val sharedContext = Kovenant.createContext("LokiAPISharedContext")
val messageSendingContext = Kovenant.createContext("LokiAPIMessageSendingContext")
val messagePollingContext = Kovenant.createContext("LokiAPIMessagePollingContext")
internal var snodeFailureCount: MutableMap<Snode, Int> = mutableMapOf()
internal var snodePool: Set<Snode>
get() = database.getSnodePool()
set(newValue) { database.setSnodePool(newValue) }
// Settings
private val maxRetryCount = 6
private val minimumSnodePoolCount = 64
private val minimumSwarmSnodeCount = 2
private val seedNodePool: Set<String> = setOf( "https://storage.seed1.loki.network", "https://storage.seed3.loki.network", "https://public.loki.foundation" )
internal val snodeFailureThreshold = 4
private val targetSwarmSnodeCount = 2
private val useOnionRequests = true
internal var powDifficulty = 1
// Error
internal sealed class Error(val description: String) : Exception() {
object Generic : Error("An error occurred.")
object ClockOutOfSync : Error("The user's clock is out of sync with the service node network.")
object RandomSnodePoolUpdatingFailed : Error("Failed to update random service node pool.")
}
// Internal API
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String, parameters: Map<String, String>): RawResponsePromise {
val url = "${snode.address}:${snode.port}/storage_rpc/v1"
if (useOnionRequests) {
return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey)
} else {
val deferred = deferred<Map<*, *>, Exception>()
Thread {
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
try {
val json = HTTP.execute(HTTP.Verb.POST, url, payload)
deferred.resolve(json)
} catch (exception: Exception) {
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
if (httpRequestFailedException != null) {
val error = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
if (error != null) { return@Thread deferred.reject(exception) }
}
Log.d("Loki", "Unhandled exception: $exception.")
deferred.reject(exception)
}
}.start()
return deferred.promise
}
}
internal fun getRandomSnode(): Promise<Snode, Exception> {
val snodePool = this.snodePool
if (snodePool.count() < minimumSnodePoolCount) {
val target = seedNodePool.random()
val url = "$target/json_rpc"
Log.d("Loki", "Populating snode pool using: $target.")
val parameters = mapOf(
"method" to "get_n_service_nodes",
"params" to mapOf(
"active_only" to true,
"fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true )
)
)
val deferred = deferred<Snode, Exception>()
deferred<org.session.libsignal.service.loki.api.Snode, Exception>(SnodeAPI.sharedContext)
Thread {
try {
val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true)
val intermediate = json["result"] as? Map<*, *>
val rawSnodes = intermediate?.get("service_node_states") as? List<*>
if (rawSnodes != null) {
val snodePool = rawSnodes.mapNotNull { rawSnode ->
val rawSnodeAsJSON = rawSnode as? Map<*, *>
val address = rawSnodeAsJSON?.get("public_ip") as? String
val port = rawSnodeAsJSON?.get("storage_port") as? Int
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
} else {
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
null
}
}.toMutableSet()
Log.d("Loki", "Persisting snode pool to database.")
this.snodePool = snodePool
try {
deferred.resolve(snodePool.getRandomElement())
} catch (exception: Exception) {
Log.d("Loki", "Got an empty snode pool from: $target.")
deferred.reject(SnodeAPI.Error.Generic)
}
} else {
Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.")
deferred.reject(SnodeAPI.Error.Generic)
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}.start()
return deferred.promise
} else {
return Promise.of(snodePool.getRandomElement())
}
}
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
val swarm = database.getSwarm(publicKey)?.toMutableSet()
if (swarm != null && swarm.contains(snode)) {
swarm.remove(snode)
database.setSwarm(publicKey, swarm)
}
}
internal fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
// SecureRandom() should be cryptographically secure
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() }
}
// Public API
fun getTargetSnodes(publicKey: String): Promise<List<Snode>, Exception> {
// SecureRandom() should be cryptographically secure
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }
}
fun getSwarm(publicKey: String): Promise<Set<Snode>, Exception> {
val cachedSwarm = database.getSwarm(publicKey)
if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue
cachedSwarmCopy.addAll(cachedSwarm)
return task { cachedSwarmCopy }
} else {
val parameters = mapOf( "pubKey" to publicKey )
return getRandomSnode().bind {
invoke(Snode.Method.GetSwarm, it, publicKey, parameters)
}.map(SnodeAPI.sharedContext) {
parseSnodes(it).toSet()
}.success {
database.setSwarm(publicKey, it)
}
}
}
fun getRawMessages(snode: Snode, publicKey: String): RawResponsePromise {
val lastHashValue = database.getLastMessageHashValue(snode, publicKey) ?: ""
val parameters = mapOf( "pubKey" to publicKey, "lastHash" to lastHashValue )
return invoke(Snode.Method.GetMessages, snode, publicKey, parameters)
}
fun getMessages(publicKey: String): MessageListPromise {
return retryIfNeeded(maxRetryCount) {
getSingleTargetSnode(publicKey).bind(messagePollingContext) { snode ->
getRawMessages(snode, publicKey).map(messagePollingContext) { parseRawMessagesResponse(it, snode, publicKey) }
}
}
}
fun sendMessage(message: SnodeMessage): Promise<Set<RawResponsePromise>, Exception> {
val destination = message.recipient
fun broadcast(event: String) {
val dayInMs: Long = 86400000
if (message.ttl != dayInMs && message.ttl != 4 * dayInMs) { return }
broadcaster.broadcast(event, message.timestamp)
}
broadcast("calculatingPoW")
return retryIfNeeded(maxRetryCount) {
getTargetSnodes(destination).map(messageSendingContext) { swarm ->
swarm.map { snode ->
broadcast("sendingMessage")
val parameters = message.toJSON()
retryIfNeeded(maxRetryCount) {
invoke(Snode.Method.SendMessage, snode, destination, parameters).map(messageSendingContext) { rawResponse ->
val json = rawResponse as? Map<*, *>
val powDifficulty = json?.get("difficulty") as? Int
if (powDifficulty != null) {
if (powDifficulty != SnodeAPI.powDifficulty && powDifficulty < 100) {
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
SnodeAPI.powDifficulty = powDifficulty
}
} else {
Log.d("Loki", "Failed to update proof of work difficulty from: ${rawResponse.prettifiedDescription()}.")
}
rawResponse
}
}
}.toSet()
}
}
}
// Parsing
private fun parseSnodes(rawResponse: Any): List<Snode> {
val json = rawResponse as? Map<*, *>
val rawSnodes = json?.get("snodes") as? List<*>
if (rawSnodes != null) {
return rawSnodes.mapNotNull { rawSnode ->
val rawSnodeAsJSON = rawSnode as? Map<*, *>
val address = rawSnodeAsJSON?.get("ip") as? String
val portAsString = rawSnodeAsJSON?.get("port") as? String
val port = portAsString?.toInt()
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
} else {
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
null
}
}
} else {
Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.")
return listOf()
}
}
private fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List<Envelope> {
val messages = rawResponse["messages"] as? List<*>
return if (messages != null) {
updateLastMessageHashValueIfPossible(snode, publicKey, messages)
val newRawMessages = removeDuplicates(publicKey, messages)
parseEnvelopes(newRawMessages)
} else {
listOf()
}
}
private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>) {
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
val hashValue = lastMessageAsJSON?.get("hash") as? String
val expiration = lastMessageAsJSON?.get("expiration") as? Int
if (hashValue != null) {
database.setLastMessageHashValue(snode, publicKey, hashValue)
} else if (rawMessages.isNotEmpty()) {
Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.")
}
}
private fun removeDuplicates(publicKey: String, rawMessages: List<*>): List<*> {
val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey)?.toMutableSet() ?: mutableSetOf()
return rawMessages.filter { rawMessage ->
val rawMessageAsJSON = rawMessage as? Map<*, *>
val hashValue = rawMessageAsJSON?.get("hash") as? String
if (hashValue != null) {
val isDuplicate = receivedMessageHashValues.contains(hashValue)
receivedMessageHashValues.add(hashValue)
database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues)
!isDuplicate
} else {
Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.")
false
}
}
}
private fun parseEnvelopes(rawMessages: List<*>): List<Envelope> {
return rawMessages.mapNotNull { rawMessage ->
val rawMessageAsJSON = rawMessage as? Map<*, *>
val base64EncodedData = rawMessageAsJSON?.get("data") as? String
val data = base64EncodedData?.let { Base64.decode(it) }
if (data != null) {
try {
MessageWrapper.unwrap(data)
} catch (e: Exception) {
Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.")
null
}
} else {
Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.")
null
}
}
}
// Error Handling
internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception? {
fun handleBadSnode() {
val oldFailureCount = snodeFailureCount[snode] ?: 0
val newFailureCount = oldFailureCount + 1
snodeFailureCount[snode] = newFailureCount
Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.")
if (newFailureCount >= snodeFailureThreshold) {
Log.d("Loki", "Failure threshold reached for: $snode; dropping it.")
if (publicKey != null) {
dropSnodeFromSwarmIfNeeded(snode, publicKey)
}
snodePool = snodePool.toMutableSet().minus(snode).toSet()
Log.d("Loki", "Snode pool count: ${snodePool.count()}.")
snodeFailureCount[snode] = 0
}
}
when (statusCode) {
400, 500, 503 -> { // Usually indicates that the snode isn't up to date
handleBadSnode()
}
406 -> {
Log.d("Loki", "The user's clock is out of sync with the service node network.")
broadcaster.broadcast("clockOutOfSync")
return Error.ClockOutOfSync
}
421 -> {
// The snode isn't associated with the given public key anymore
if (publicKey != null) {
Log.d("Loki", "Invalidating swarm for: $publicKey.")
dropSnodeFromSwarmIfNeeded(snode, publicKey)
} else {
Log.d("Loki", "Got a 421 without an associated public key.")
}
}
432 -> {
// The PoW difficulty is too low
val powDifficulty = json?.get("difficulty") as? Int
if (powDifficulty != null) {
if (powDifficulty < 100) {
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
SnodeAPI.powDifficulty = powDifficulty
} else {
handleBadSnode()
}
} else {
Log.d("Loki", "Failed to update proof of work difficulty.")
}
}
else -> {
handleBadSnode()
Log.d("Loki", "Unhandled response code: ${statusCode}.")
return Error.Generic
}
}
return null
}
}
// Type Aliases
typealias RawResponse = Map<*, *>
typealias MessageListPromise = Promise<List<Envelope>, Exception>
typealias RawResponsePromise = Promise<RawResponse, Exception>

View File

@ -0,0 +1,23 @@
package org.session.libsession.snode
data class SnodeMessage(
// The hex encoded public key of the recipient.
val recipient: String,
// The content of the message.
val data: String,
// The time to live for the message in milliseconds.
val ttl: Long,
// When the proof of work was calculated.
val timestamp: Long,
// The base 64 encoded proof of work.
val nonce: String
) {
internal fun toJSON(): Map<String, String> {
return mutableMapOf(
"pubKey" to recipient,
"data" to data,
"ttl" to ttl.toString(),
"timestamp" to timestamp.toString(),
"nonce" to nonce)
}
}

View File

@ -0,0 +1,15 @@
package org.session.libsession.snode
interface SnodeStorageProtocol {
fun getSnodePool(): Set<Snode>
fun setSnodePool(newValue: Set<Snode>)
fun getOnionRequestPaths(): List<List<Snode>>
fun clearOnionRequestPaths()
fun setOnionRequestPaths(newValue: List<List<Snode>>)
fun getSwarm(publicKey: String): Set<Snode>?
fun setSwarm(publicKey: String, newValue: Set<Snode>)
fun getLastMessageHashValue(snode: Snode, publicKey: String): String?
fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String)
fun getReceivedMessageHashValues(publicKey: String): Set<String>?
fun setReceivedMessageHashValues(publicKey: String, newValue: Set<String>)
}

View File

@ -0,0 +1,49 @@
package org.session.libsession.utilities
import okhttp3.MultipartBody
import okhttp3.Request
import okio.Buffer
import org.session.libsignal.service.internal.util.Base64
import java.io.IOException
import java.util.*
internal fun Request.getHeadersForOnionRequest(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
val contentType = body()?.contentType()
if (contentType != null) {
result["content-type"] = contentType.toString()
}
val headers = headers()
for (name in headers.names()) {
val value = headers.get(name)
if (value != null) {
if (value.toLowerCase(Locale.US) == "true" || value.toLowerCase(Locale.US) == "false") {
result[name] = value.toBoolean()
} else if (value.toIntOrNull() != null) {
result[name] = value.toInt()
} else {
result[name] = value
}
}
}
return result
}
internal fun Request.getBodyForOnionRequest(): Any? {
try {
val copyOfThis = newBuilder().build()
val buffer = Buffer()
val body = copyOfThis.body() ?: return null
body.writeTo(buffer)
val bodyAsData = buffer.readByteArray()
if (body is MultipartBody) {
val base64EncodedBody: String = Base64.encodeBytes(bodyAsData)
return mapOf( "fileUpload" to base64EncodedBody )
} else {
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
return bodyAsData?.toString(charset)
}
} catch (e: IOException) {
return null
}
}

View File

@ -0,0 +1,18 @@
package org.session.libsession.snode.utilities
import java.security.SecureRandom
/**
* Uses `SecureRandom` to pick an element from this collection.
*/
fun <T> Collection<T>.getRandomElementOrNull(): T? {
val index = SecureRandom().nextInt(size) // SecureRandom() should be cryptographically secure
return elementAtOrNull(index)
}
/**
* Uses `SecureRandom` to pick an element from this collection.
*/
fun <T> Collection<T>.getRandomElement(): T {
return getRandomElementOrNull()!!
}

View File

@ -0,0 +1,58 @@
package org.session.libsession.utilities
import org.whispersystems.curve25519.Curve25519
import org.session.libsignal.libsignal.util.ByteUtil
import org.session.libsignal.libsignal.util.Hex
import org.session.libsignal.service.internal.util.Util
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
internal object AESGCM {
internal data class EncryptionResult(
internal val ciphertext: ByteArray,
internal val symmetricKey: ByteArray,
internal val ephemeralPublicKey: ByteArray
)
internal val gcmTagSize = 128
internal val ivSize = 12
/**
* Sync. Don't call from the main thread.
*/
internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray {
val iv = ivAndCiphertext.sliceArray(0 until ivSize)
val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count())
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
return cipher.doFinal(ciphertext)
}
/**
* Sync. Don't call from the main thread.
*/
internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray {
val iv = Util.getSecretBytes(ivSize)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
return ByteUtil.combine(iv, cipher.doFinal(plaintext))
}
/**
* Sync. Don't call from the main thread.
*/
internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult {
val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey)
val ephemeralKeyPair = Curve25519.getInstance(Curve25519.BEST).generateKeyPair()
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, ephemeralKeyPair.privateKey)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
val ciphertext = encrypt(plaintext, symmetricKey)
return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey)
}
}