move and refactor files from libsignal to libsession

This commit is contained in:
Ryan ZHAO 2020-12-02 16:38:12 +11:00
parent 5789c146de
commit c1f84732ad
19 changed files with 2616 additions and 2 deletions

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

@ -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: OpenGroupMessage.Quote? = {
val quote = message.quote
if (quote != null && quote.isValid) {
val quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey)
OpenGroupMessage.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 = OpenGroupMessage.Attachment(
OpenGroupMessage.Attachment.Kind.LinkPreview,
server,
attachment.getId(),
attachment.getContentType(),
attachment.getSize(),
attachment.getFileName(),
attachment.getFlags(),
attachment.getWidth(),
attachment.getHeight(),
attachment.getCaption(),
attachment.getUrl(),
linkPreview.getUrl(),
linkPreview.getTitle())
result.attachments.add(openGroupLinkPreview)
}
// Attachments
val attachments = message.getAttachemnts().forEach {
val attachement = OpenGroupMessage.Attachment(
OpenGroupMessage.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,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,121 @@
package org.session.libsession.messaging.utilities;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.session.libsignal.libsignal.util.guava.Optional;
import org.session.libsignal.metadata.SignalProtos;
import org.session.libsignal.metadata.certificate.CertificateValidator;
import org.session.libsignal.metadata.certificate.InvalidCertificateException;
import org.session.libsignal.service.api.crypto.UnidentifiedAccess;
import org.session.libsignal.service.api.crypto.UnidentifiedAccessPair;
import org.session.libsignal.service.api.push.SignalServiceAddress;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
public class UnidentifiedAccessUtil {
private static final String TAG = UnidentifiedAccessUtil.class.getSimpleName();
public static CertificateValidator getCertificateValidator() {
return new CertificateValidator();
}
@WorkerThread
public static Optional<UnidentifiedAccessPair> getAccessFor(@NonNull Context context,
@NonNull Recipient recipient)
{
if (!TextSecurePreferences.isUnidentifiedDeliveryEnabled(context)) {
Log.i(TAG, "Unidentified delivery is disabled. [other]");
return Optional.absent();
}
try {
byte[] theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
byte[] ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey(context);
byte[] ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate(context);
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) {
ourUnidentifiedAccessKey = Util.getSecretBytes(16);
}
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 Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(theirUnidentifiedAccessKey,
ourUnidentifiedAccessCertificate),
new UnidentifiedAccess(ourUnidentifiedAccessKey,
ourUnidentifiedAccessCertificate)));
}
return Optional.absent();
} catch (InvalidCertificateException e) {
Log.w(TAG, e);
return Optional.absent();
}
}
public static Optional<UnidentifiedAccessPair> getAccessForSync(@NonNull Context context) {
if (!TextSecurePreferences.isUnidentifiedDeliveryEnabled(context)) {
Log.i(TAG, "Unidentified delivery is disabled. [self]");
return Optional.absent();
}
try {
byte[] ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey(context);
byte[] ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate(context);
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) {
ourUnidentifiedAccessKey = Util.getSecretBytes(16);
}
if (ourUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) {
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(ourUnidentifiedAccessKey,
ourUnidentifiedAccessCertificate),
new UnidentifiedAccess(ourUnidentifiedAccessKey,
ourUnidentifiedAccessCertificate)));
}
return Optional.absent();
} catch (InvalidCertificateException e) {
Log.w(TAG, e);
return Optional.absent();
}
}
public static @NonNull byte[] getSelfUnidentifiedAccessKey(@NonNull Context context) {
return UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getProfileKey(context));
}
private static @Nullable byte[] getTargetUnidentifiedAccessKey(@NonNull Recipient recipient) {
byte[] theirProfileKey = recipient.resolve().getProfileKey();
if (theirProfileKey == null) return Util.getSecretBytes(16);
else return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey);
}
private static @Nullable byte[] getUnidentifiedAccessCertificate(Context context) {
String ourNumber = TextSecurePreferences.getLocalNumber(context);
if (ourNumber != null) {
SignalProtos.SenderCertificate certificate = SignalProtos.SenderCertificate.newBuilder()
.setSender(ourNumber)
.setSenderDevice(SignalServiceAddress.DEFAULT_DEVICE_ID)
.build();
return certificate.toByteArray();
}
return null;
}
}

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