Fix duplicated API

pull/503/head
Niels Andriesse 3 years ago
parent 9f26436041
commit 979c21ccbf

@ -32,12 +32,15 @@ import androidx.multidex.MultiDexApplication;
import org.conscrypt.Conscrypt;
import org.session.libsession.messaging.MessagingConfiguration;
import org.session.libsession.messaging.avatars.AvatarHelper;
import org.session.libsession.messaging.fileserver.FileServerAPI;
import org.session.libsession.messaging.jobs.JobQueue;
import org.session.libsession.messaging.opengroups.OpenGroupAPI;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI;
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller;
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.messaging.threads.Address;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.snode.SnodeConfiguration;
import org.session.libsession.utilities.IdentityKeyUtil;
import org.session.libsession.utilities.SSKEnvironment;
@ -47,10 +50,6 @@ import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWr
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsession.utilities.preferences.ProfileKeyUtil;
import org.session.libsignal.service.api.util.StreamDetails;
import org.session.libsignal.service.loki.api.PushNotificationAPI;
import org.session.libsignal.service.loki.api.SnodeAPI;
import org.session.libsignal.service.loki.api.SwarmAPI;
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI;
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol;
import org.session.libsignal.service.loki.utilities.mentions.MentionsManager;
import org.session.libsignal.utilities.logging.Log;
@ -96,6 +95,7 @@ import org.webrtc.voiceengine.WebRtcAudioUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Date;
@ -179,11 +179,8 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
new SessionProtocolImpl(this));
SnodeConfiguration.Companion.configure(apiDB, broadcaster);
if (userPublicKey != null) {
SwarmAPI.Companion.configureIfNeeded(apiDB);
SnodeAPI.Companion.configureIfNeeded(userPublicKey, apiDB, broadcaster);
MentionsManager.Companion.configureIfNeeded(userPublicKey, threadDB, userDB);
}
PushNotificationAPI.Companion.configureIfNeeded(BuildConfig.DEBUG);
setUpStorageAPIIfNeeded();
resubmitProfilePictureIfNeeded();
publicChatManager = new PublicChatManager(this);
@ -427,7 +424,6 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
}
byte[] userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).getPrivateKey().serialize();
LokiAPIDatabaseProtocol apiDB = DatabaseFactory.getLokiAPIDatabase(this);
FileServerAPI.Companion.configure(userPublicKey, userPrivateKey, apiDB);
org.session.libsession.messaging.fileserver.FileServerAPI.Companion.configure(userPublicKey, userPrivateKey, apiDB);
return true;
}
@ -458,13 +454,10 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return;
if (poller != null) {
SnodeAPI.shared.setUserPublicKey(userPublicKey);
poller.setUserPublicKey(userPublicKey);
return;
}
LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this);
SwarmAPI.Companion.configureIfNeeded(apiDB);
SnodeAPI.Companion.configureIfNeeded(userPublicKey, apiDB, broadcaster);
poller = new Poller();
closedGroupPoller = new ClosedGroupPoller();
}
@ -503,12 +496,13 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
try {
File profilePicture = AvatarHelper.getAvatarFile(this, Address.fromSerialized(userPublicKey));
StreamDetails stream = new StreamDetails(new FileInputStream(profilePicture), "image/jpeg", profilePicture.length());
FileServerAPI.shared.uploadProfilePicture(FileServerAPI.shared.getServer(), profileKey, stream, () -> {
TextSecurePreferences.setLastProfilePictureUpload(this, new Date().getTime());
TextSecurePreferences.setProfileAvatarId(this, new SecureRandom().nextInt());
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey);
return Unit.INSTANCE;
});
throw new IOException();
// FileServerAPI.uploadProfilePicture(FileServerAPI.shared.getServer(), profileKey, stream, () -> {
// TextSecurePreferences.setLastProfilePictureUpload(this, new Date().getTime());
// TextSecurePreferences.setProfileAvatarId(this, new SecureRandom().nextInt());
// ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey);
// return Unit.INSTANCE;
// });
} catch (Exception exception) {
// Do nothing
}

@ -19,12 +19,12 @@ import android.widget.Toast
import androidx.annotation.ColorRes
import kotlinx.android.synthetic.main.activity_path.*
import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.loki.utilities.*
import org.thoughtcrime.securesms.loki.views.GlowViewUtilities
import org.thoughtcrime.securesms.loki.views.PathDotView
import org.session.libsignal.service.loki.api.Snode
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
class PathActivity : PassphraseRequiredActionBarActivity() {
private val broadcastReceivers = mutableListOf<BroadcastReceiver>()

@ -28,13 +28,13 @@ import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.task
import nl.komponents.kovenant.ui.alwaysUi
import org.session.libsession.messaging.avatars.AvatarHelper
import org.session.libsession.messaging.fileserver.FileServerAPI
import org.session.libsession.messaging.opengroups.OpenGroupAPI
import org.session.libsession.messaging.threads.Address
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.preferences.ProfileKeyUtil
import org.session.libsignal.service.api.util.StreamDetails
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection

@ -10,11 +10,12 @@ import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.opengroups.OpenGroup
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.service.loki.api.SnodeAPI
import org.session.libsignal.utilities.logging.Log
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseFactory
import java.io.IOException
import java.util.concurrent.TimeUnit
class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Worker(context, params) {
@ -70,10 +71,11 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
// Private chats
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val privateChatsPromise = SnodeAPI.shared.getMessages(userPublicKey).map { envelopes ->
envelopes.map { envelope ->
MessageReceiveJob(envelope.toByteArray(), false).executeAsync()
}
val privateChatsPromise = SnodeAPI.getMessages(userPublicKey).map { envelopes ->
throw IOException()
// envelopes.map { envelope ->
// MessageReceiveJob(envelope.toByteArray(), false).executeAsync()
// }
}
promises.addAll(privateChatsPromise.get())

@ -3,12 +3,12 @@ package org.thoughtcrime.securesms.loki.api
import android.content.Context
import nl.komponents.kovenant.functional.map
import okhttp3.*
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.PushNotificationAPI
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.utilities.retryIfNeeded
object LokiPushNotificationManager {
@ -16,10 +16,10 @@ object LokiPushNotificationManager {
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
private val server by lazy {
PushNotificationAPI.shared.server
PushNotificationAPI.server
}
private val pnServerPublicKey by lazy {
PushNotificationAPI.pnServerPublicKey
PushNotificationAPI.serverPublicKey
}
enum class ClosedGroupOperation {

@ -7,7 +7,7 @@ import android.content.IntentFilter
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import org.session.libsignal.utilities.logging.Log
import com.opencsv.CSVReader
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.ThreadUtils
import java.io.File
import java.io.FileOutputStream

@ -11,9 +11,9 @@ import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.loki.utilities.getColorWithID
import org.thoughtcrime.securesms.loki.utilities.toPx
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
class PathStatusView : View {
private val broadcastReceivers = mutableListOf<BroadcastReceiver>()

@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.mms;
import android.content.Context;
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI;
import org.session.libsession.messaging.fileserver.FileServerAPI;
public class PushMediaConstraints extends MediaConstraints {

@ -4,10 +4,10 @@ import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.utilities.*
import java.net.URL

@ -8,12 +8,12 @@ import nl.komponents.kovenant.then
import org.session.libsession.messaging.MessagingConfiguration
import org.session.libsession.messaging.fileserver.FileServerAPI
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsignal.service.loki.utilities.DownloadUtilities
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.utilities.*
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.logging.Log
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
@ -316,7 +316,7 @@ object OpenGroupAPI: DotNetAPI() {
Log.d("Loki", "Downloading open group profile picture from \"$url\".")
val outputStream = ByteArrayOutputStream()
try {
DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null)
throw IOException();
Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"")
return outputStream.toByteArray()
} catch (e: Exception) {

@ -23,7 +23,6 @@ import org.session.libsession.snode.SnodeMessage
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsignal.service.internal.push.PushTransportDetails
import org.session.libsignal.service.internal.push.SignalServiceProtos
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.logging.Log
@ -151,11 +150,9 @@ object MessageSender {
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
SnodeConfiguration.shared.broadcaster.broadcast("calculatingPoW", message.sentTimestamp!!)
}
val recipient = message.recipient!!
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
val nonce = ProofOfWork.calculate(base64EncodedData, recipient, message.sentTimestamp!!, message.ttl.toInt()) ?: throw Error.ProofOfWorkCalculationFailed
// Send the result
val snodeMessage = SnodeMessage(recipient, base64EncodedData, message.ttl, message.sentTimestamp!!, nonce)
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
val snodeMessage = SnodeMessage(message.recipient!!, base64EncodedData, message.ttl, message.sentTimestamp!!)
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
SnodeConfiguration.shared.broadcaster.broadcast("sendingMessage", message.sentTimestamp!!)
}

@ -6,8 +6,8 @@ import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.logging.Log

@ -6,12 +6,12 @@ import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsession.messaging.fileserver.FileServerAPI
import org.session.libsession.utilities.AESGCM
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.*
import org.session.libsignal.service.loki.api.Snode
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.libsignal.utilities.ThreadUtils

@ -8,9 +8,7 @@ data class SnodeMessage(
// 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
val timestamp: Long
) {
internal fun toJSON(): Map<String, String> {
return mutableMapOf(
@ -18,6 +16,6 @@ data class SnodeMessage(
"data" to data,
"ttl" to ttl.toString(),
"timestamp" to timestamp.toString(),
"nonce" to nonce)
"nonce" to "")
}
}

@ -12,7 +12,6 @@ import org.session.libsignal.service.api.crypto.ProfileCipherInputStream;
import org.session.libsignal.service.api.messages.SignalServiceAttachment.ProgressListener;
import org.session.libsignal.service.api.messages.SignalServiceAttachmentPointer;
import org.session.libsignal.service.api.messages.SignalServiceDataMessage;
import org.session.libsignal.service.loki.utilities.DownloadUtilities;
import java.io.File;
import java.io.FileInputStream;
@ -46,8 +45,7 @@ public class SignalServiceMessageReceiver {
public InputStream retrieveProfileAvatar(String path, File destination, byte[] profileKey, int maxSizeBytes)
throws IOException
{
DownloadUtilities.downloadFile(destination, path, maxSizeBytes, null);
return new ProfileCipherInputStream(new FileInputStream(destination), profileKey);
throw new IOException();
}
/**
@ -65,13 +63,6 @@ public class SignalServiceMessageReceiver {
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, int maxSizeBytes, ProgressListener listener)
throws IOException, InvalidMessageException
{
// Loki - Fetch attachment
if (pointer.getUrl().isEmpty()) throw new InvalidMessageException("Missing attachment URL.");
DownloadUtilities.downloadFile(destination, pointer.getUrl(), maxSizeBytes, listener);
// Loki - Assume we're retrieving an attachment for an open group server if the digest is not set
if (!pointer.getDigest().isPresent()) { return new FileInputStream(destination); }
return AttachmentCipherInputStream.createForAttachment(destination, pointer.getSize().or(0), pointer.getKey(), pointer.getDigest().get());
throw new IOException();
}
}

@ -1,252 +0,0 @@
package org.session.libsignal.service.loki.api
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.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.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.utilities.Base64
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.api.utilities.HTTP
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.utilities.recover
import java.util.*
/**
* Base class that provides utilities for .NET based APIs.
*/
open class LokiDotNetAPI(internal val userPublicKey: String, private val userPrivateKey: ByteArray, private val apiDatabase: LokiAPIDatabaseProtocol) {
internal enum class HTTPVerb { GET, PUT, POST, DELETE, PATCH }
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 token = apiDatabase.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 ->
apiDatabase.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 parameters: Map<String, Any> = mapOf( "pubKey" to userPublicKey )
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, userPrivateKey)
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 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) {
apiDatabase.setAuthToken(server, null)
throw SnodeAPI.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 SnodeAPI.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 SnodeAPI.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 SnodeAPI.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 SnodeAPI.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 SnodeAPI.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) {
apiDatabase.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 }

@ -1,86 +0,0 @@
package org.session.libsignal.service.loki.api
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
import org.session.libsignal.service.loki.utilities.TTLUtilities
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.service.loki.utilities.prettifiedDescription
internal data class LokiMessage(
/**
* The hex encoded public key of the receiver.
*/
internal val recipientPublicKey: String,
/**
* The content of the message.
*/
internal val data: String,
/**
* The time to live for the message in milliseconds.
*/
internal val ttl: Int,
/**
* Whether this message is a ping.
*/
internal val isPing: Boolean,
/**
* When the proof of work was calculated, if applicable (P2P messages don't require proof of work).
*
* - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970.
*/
internal var timestamp: Long? = null,
/**
* The base 64 encoded proof of work, if applicable (P2P messages don't require proof of work).
*/
internal var nonce: String? = null
) {
internal companion object {
internal fun from(message: SignalMessageInfo): LokiMessage? {
try {
val wrappedMessage = MessageWrapper.wrap(message)
val data = Base64.encodeBytes(wrappedMessage)
val destination = message.recipientPublicKey
var ttl = TTLUtilities.fallbackMessageTTL
val messageTTL = message.ttl
if (messageTTL != null && messageTTL != 0) { ttl = messageTTL }
val isPing = message.isPing
return LokiMessage(destination, data, ttl, isPing)
} catch (e: Exception) {
Log.d("Loki", "Failed to convert Signal message to Loki message: ${message.prettifiedDescription()}.")
return null
}
}
}
@kotlin.ExperimentalUnsignedTypes
internal fun calculatePoW(): Promise<LokiMessage, Exception> {
val deferred = deferred<LokiMessage, Exception>()
// Run PoW in a background thread
ThreadUtils.queue {
val now = System.currentTimeMillis()
val nonce = ProofOfWork.calculate(data, recipientPublicKey, now, ttl)
if (nonce != null ) {
deferred.resolve(copy(nonce = nonce, timestamp = now))
} else {
deferred.reject(SnodeAPI.Error.ProofOfWorkCalculationFailed)
}
}
return deferred.promise
}
internal fun toJSON(): Map<String, String> {
val result = mutableMapOf( "pubKey" to recipientPublicKey, "data" to data, "ttl" to ttl.toString() )
val timestamp = timestamp
val nonce = nonce
if (timestamp != null && nonce != null) {
result["timestamp"] = timestamp.toString()
result["nonce"] = nonce
}
return result
}
}

@ -1,42 +0,0 @@
package org.session.libsignal.service.loki.api
import nl.komponents.kovenant.functional.map
import okhttp3.*
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.utilities.retryIfNeeded
public class PushNotificationAPI private constructor(public val server: String) {
companion object {
private val maxRetryCount = 4
public val pnServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
lateinit var shared: PushNotificationAPI
public fun configureIfNeeded(isDebugMode: Boolean) {
if (::shared.isInitialized) { return; }
val server = if (isDebugMode) "https://live.apns.getsession.org" else "https://live.apns.getsession.org"
shared = PushNotificationAPI(server)
}
}
public fun notify(messageInfo: SignalMessageInfo) {
val message = LokiMessage.from(messageInfo) ?: return
val parameters = mapOf( "data" to message.data, "send_to" to message.recipientPublicKey )
val url = "${server}/notify"
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, PushNotificationAPI.pnServerPublicKey, "/loki/v2/lsrpc").map { json ->
val code = json["code"] as? Int
if (code == null || code == 0) {
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: $exception.")
}
}
}
}

@ -1,280 +0,0 @@
package org.session.libsignal.service.loki.api
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.libsignal.utilities.logging.Log
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
import org.session.libsignal.utilities.Base64
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.api.utilities.HTTP
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.utilities.*
import org.session.libsignal.utilities.*
import java.net.ConnectException
import java.net.SocketTimeoutException
class SnodeAPI private constructor(public var userPublicKey: String, public val database: LokiAPIDatabaseProtocol, public val broadcaster: Broadcaster) {
companion object {
val messageSendingContext = Kovenant.createContext()
val messagePollingContext = Kovenant.createContext()
/**
* For operations that are shared between message sending and message polling.
*/
val sharedContext = Kovenant.createContext()
// region Initialization
lateinit var shared: SnodeAPI
fun configureIfNeeded(userHexEncodedPublicKey: String, database: LokiAPIDatabaseProtocol, broadcaster: Broadcaster) {
if (::shared.isInitialized) { return; }
shared = SnodeAPI(userHexEncodedPublicKey, database, broadcaster)
}
// endregion
// region Settings
private val maxRetryCount = 6
private val useOnionRequests = true
internal var powDifficulty = 1
// endregion
}
// region Error
sealed class Error(val description: String) : Exception() {
class HTTPRequestFailed(val code: Int) : Error("HTTP request failed with error code: $code.")
object Generic : Error("An error occurred.")
object ResponseBodyMissing: Error("Response body missing.")
object MessageSigningFailed: Error("Failed to sign message.")
/**
* Only applicable to snode targets as proof of work isn't required for P2P messaging.
*/
object ProofOfWorkCalculationFailed : Error("Failed to calculate proof of work.")
object MessageConversionFailed : Error("Failed to convert Signal message to Loki message.")
object ClockOutOfSync : Error("The user's clock is out of sync with the service node network.")
object SnodeMigrated : Error("The snode previously associated with the given public key has migrated to a different swarm.")
object InsufficientProofOfWork : Error("The proof of work is insufficient.")
object TokenExpired : Error("The auth token being used has expired.")
object ParsingFailed : Error("Couldn't parse JSON.")
}
// endregion
// region Internal API
/**
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
*/
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>()
ThreadUtils.queue {
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) {
if (exception is ConnectException || exception is SocketTimeoutException) {
dropSnodeIfNeeded(snode, publicKey)
} else {
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
if (httpRequestFailedException != null) {
@Suppress("NAME_SHADOWING") val exception = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
return@queue deferred.reject(exception)
}
Log.d("Loki", "Unhandled exception: $exception.")
}
deferred.reject(exception)
}
}
return deferred.promise
}
}
public 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)
}
// endregion
// region Public API
fun getMessages(publicKey: String): MessageListPromise {
return retryIfNeeded(maxRetryCount) {
SwarmAPI.shared.getSingleTargetSnode(publicKey).bind(messagePollingContext) { snode ->
getRawMessages(snode, publicKey).map(messagePollingContext) { parseRawMessagesResponse(it, snode, publicKey) }
}
}
}
@kotlin.ExperimentalUnsignedTypes
fun sendSignalMessage(message: SignalMessageInfo): Promise<Set<RawResponsePromise>, Exception> {
val lokiMessage = LokiMessage.from(message) ?: return task { throw Error.MessageConversionFailed }
val destination = lokiMessage.recipientPublicKey
fun broadcast(event: String) {
val dayInMs = 86400000
if (message.ttl != dayInMs && message.ttl != 4 * dayInMs) { return }
broadcaster.broadcast(event, message.timestamp)
}
broadcast("calculatingPoW")
return lokiMessage.calculatePoW().bind { lokiMessageWithPoW ->
broadcast("contactingNetwork")
retryIfNeeded(maxRetryCount) {
SwarmAPI.shared.getTargetSnodes(destination).map { swarm ->
swarm.map { snode ->
broadcast("sendingMessage")
val parameters = lokiMessageWithPoW.toJSON()
retryIfNeeded(maxRetryCount) {
invoke(Snode.Method.SendMessage, snode, destination, parameters).map { 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()
}
}
}
}
// endregion
// region Parsing
// The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions.
public fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List<Envelope> {
val messages = rawResponse["messages"] as? List<*>
if (messages != null) {
updateLastMessageHashValueIfPossible(snode, publicKey, messages)
val newRawMessages = removeDuplicates(publicKey, messages)
return parseEnvelopes(newRawMessages)
} else {
return 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
}
}
}
// endregion
// region Error Handling
private fun dropSnodeIfNeeded(snode: Snode, publicKey: String? = null) {
val oldFailureCount = SwarmAPI.shared.snodeFailureCount[snode] ?: 0
val newFailureCount = oldFailureCount + 1
SwarmAPI.shared.snodeFailureCount[snode] = newFailureCount
Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.")
if (newFailureCount >= SwarmAPI.snodeFailureThreshold) {
Log.d("Loki", "Failure threshold reached for: $snode; dropping it.")
if (publicKey != null) {
SwarmAPI.shared.dropSnodeFromSwarmIfNeeded(snode, publicKey)
}
SwarmAPI.shared.snodePool = SwarmAPI.shared.snodePool.toMutableSet().minus(snode).toSet()
Log.d("Loki", "Snode pool count: ${SwarmAPI.shared.snodePool.count()}.")
SwarmAPI.shared.snodeFailureCount[snode] = 0
}
}
internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception {
when (statusCode) {
400, 500, 503 -> { // Usually indicates that the snode isn't up to date
dropSnodeIfNeeded(snode, publicKey)
return Error.HTTPRequestFailed(statusCode)
}
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.")
SwarmAPI.shared.dropSnodeFromSwarmIfNeeded(snode, publicKey)
} else {
Log.d("Loki", "Got a 421 without an associated public key.")
}
return Error.SnodeMigrated
}
432 -> {
// The PoW difficulty is too low
val powDifficulty = json?.get("difficulty") as? Int
if (powDifficulty != null && 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.")
}
return Error.InsufficientProofOfWork
}
else -> {
dropSnodeIfNeeded(snode, publicKey)
Log.d("Loki", "Unhandled response code: ${statusCode}.")
return Error.Generic
}
}
}
// endregion
}
// region Convenience
typealias RawResponse = Map<*, *>
typealias MessageListPromise = Promise<List<Envelope>, Exception>
typealias RawResponsePromise = Promise<RawResponse, Exception>
// endregion

@ -1,185 +0,0 @@
package org.session.libsignal.service.loki.api
import android.os.Build
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.libsignal.service.loki.api.utilities.HTTP
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.utilities.getRandomElement
import org.session.libsignal.service.loki.utilities.prettifiedDescription
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.logging.Log
import java.security.SecureRandom
import java.util.*
class SwarmAPI private constructor(private val database: LokiAPIDatabaseProtocol) {
internal var snodeFailureCount: MutableMap<Snode, Int> = mutableMapOf()
internal var snodePool: Set<Snode>
get() = database.getSnodePool()
set(newValue) { database.setSnodePool(newValue) }
companion object {
// use port 4433 if API level can handle network security config and enforce pinned certificates
private val seedPort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433
private val seedNodePool: Set<String> = setOf(
"https://storage.seed1.loki.network:$seedPort",
"https://storage.seed3.loki.network:$seedPort",
"https://public.loki.foundation:$seedPort"
)
// region Settings
private val minimumSnodePoolCount = 64
private val minimumSwarmSnodeCount = 2
private val targetSwarmSnodeCount = 2
private val maxRetryCount = 6
/**
* A snode is kicked out of a swarm and/or the snode pool if it fails this many times.
*/
internal val snodeFailureThreshold = 2
// endregion
// region Initialization
lateinit var shared: SwarmAPI
fun configureIfNeeded(database: LokiAPIDatabaseProtocol) {
if (::shared.isInitialized) { return; }
shared = SwarmAPI(database)
}
// endregion
}
// region Swarm API
internal fun getRandomSnode(): Promise<Snode, Exception> {
val snodePool = this.snodePool
val lastRefreshDate = database.getLastSnodePoolRefreshDate()
val now = Date()
val needsRefresh = (snodePool.count() < minimumSnodePoolCount) || lastRefreshDate == null || (now.time - lastRefreshDate.time) > 24 * 60 * 60 * 1000
if (needsRefresh) {
database.setLastSnodePoolRefreshDate(now)
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<Snode, Exception>(SnodeAPI.sharedContext)
ThreadUtils.queue {
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) {
@Suppress("NAME_SHADOWING") 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)
}
}
return deferred.promise
} else {
return Promise.of(snodePool.getRandomElement())
}
}
public 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 {
retryIfNeeded(maxRetryCount) {
SnodeAPI.shared.invoke(Snode.Method.GetSwarm, it, publicKey, parameters)
}
}.map(SnodeAPI.sharedContext) {
parseSnodes(it).toSet()
}.success {
database.setSwarm(publicKey, it)
}
}
}
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() }
}
internal fun getTargetSnodes(publicKey: String): Promise<List<Snode>, Exception> {
// SecureRandom() should be cryptographically secure
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }
}
// endregion
// region 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()
}
}
// endregion
}

@ -1,64 +0,0 @@
package org.session.libsignal.service.loki.api.crypto
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.service.loki.api.SnodeAPI
import java.math.BigInteger
import java.nio.ByteBuffer
import java.security.MessageDigest
/**
* Based on the desktop messenger's proof of work implementation. For more information, see libloki/proof-of-work.js.
*/
object ProofOfWork {
// region Settings
private val nonceSize = 8
// endregion
// region Implementation
@kotlin.ExperimentalUnsignedTypes
fun calculate(data: String, hexEncodedPublicKey: String, timestamp: Long, ttl: Int): String? {
try {
val sha512 = MessageDigest.getInstance("SHA-512")
val payloadAsString = timestamp.toString() + ttl.toString() + hexEncodedPublicKey + data
val payload = payloadAsString.toByteArray()
val target = determineTarget(ttl, payload.size)
var currentTrialValue = ULong.MAX_VALUE
var nonce: Long = 0
val initialHash = sha512.digest(payload)
while (currentTrialValue > target) {
nonce += 1
// This is different from bitmessage's PoW implementation
// newHash = hash(nonce + hash(data)) → hash(nonce + initialHash)
val newHash = sha512.digest(nonce.toByteArray() + initialHash)
currentTrialValue = newHash.sliceArray(0 until nonceSize).toULong()
}
return Base64.encodeBytes(nonce.toByteArray())
} catch (e: Exception) {
Log.d("Loki", "Couldn't calculate proof of work due to error: $e.")
return null
}
}
@kotlin.ExperimentalUnsignedTypes
private fun determineTarget(ttl: Int, payloadSize: Int): ULong {
val x1 = BigInteger.valueOf(2).pow(16) - 1.toBigInteger()
val x2 = BigInteger.valueOf(2).pow(64) - 1.toBigInteger()
val size = (payloadSize + nonceSize).toBigInteger()
val ttlInSeconds = (ttl / 1000).toBigInteger()
val x3 = (ttlInSeconds * size) / x1
val x4 = size + x3
val x5 = SnodeAPI.powDifficulty.toBigInteger() * x4
return (x2 / x5).toULong()
}
// endregion
}
// region Convenience
@kotlin.ExperimentalUnsignedTypes
private fun BigInteger.toULong() = toLong().toULong()
private fun Long.toByteArray() = ByteBuffer.allocate(8).putLong(this).array()
@kotlin.ExperimentalUnsignedTypes
private fun ByteArray.toULong() = ByteBuffer.wrap(this).long.toULong()
// endregion

@ -1,77 +0,0 @@
package org.session.libsignal.service.loki.api.fileserver
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.JsonUtil
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.utilities.*
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, database) {
companion object {
// region Settings
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 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
}
}
}
}
}

@ -1,460 +0,0 @@
package org.session.libsignal.service.loki.api.onionrequests
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.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.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.libsignal.service.loki.api.utilities.EncryptionResult
import org.session.libsignal.service.loki.api.utilities.getBodyForOnionRequest
import org.session.libsignal.service.loki.api.utilities.getHeadersForOnionRequest
import org.session.libsignal.service.loki.utilities.*
import org.session.libsignal.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.shared.database.getOnionRequestPaths()
set(newValue) {
if (newValue.isEmpty()) {
SnodeAPI.shared.database.clearOnionRequestPaths()
} else {
SnodeAPI.shared.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 = 1
/**
* The number of times a snode can fail before it's replaced.
*/
private val snodeFailureThreshold = 1
/**
* 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.libsignal.service.loki.api.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>()
ThreadUtils.queue { // No need to block the shared context for this
val url = "${snode.address}:${snode.port}/get_stats/v1"
try {
val json = HTTP.execute(HTTP.Verb.GET, url)
val version = json["version"] as? String
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue }
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)
}
}
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 SwarmAPI.shared.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
var unusedSnodes = SwarmAPI.shared.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.shared.broadcaster.broadcast("buildingPaths")
return SwarmAPI.shared.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 = SwarmAPI.shared.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.shared.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 = SwarmAPI.shared.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>()
var guardSnode: Snode? = null
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
ThreadUtils.queue {
try {
val json = HTTP.execute(HTTP.Verb.POST, url, body)
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@queue deferred.reject(Exception("Invalid JSON"))
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
try {
val plaintext = DecryptionUtilities.decryptUsingAESGCM(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@queue deferred.reject(exception)
} else if (json["body"] != null) {
@Suppress("NAME_SHADOWING") val body: Map<*, *>
if (json["body"] is Map<*, *>) {
body = json["body"] as Map<*, *>
} else {
val bodyAsString = json["body"] as String
if (!isJSONRequired) {
body = mapOf( "result" to bodyAsString )
} else {
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
}
}
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
return@queue deferred.reject(exception)
}
deferred.resolve(body)
} else {
if (statusCode != 200) {
val exception = HTTPRequestFailedAtDestinationException(statusCode, json)
return@queue deferred.reject(exception)
}
deferred.resolve(json)
}
} catch (exception: Exception) {
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
}
} catch (exception: Exception) {
deferred.reject(exception)
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}
}.fail { exception ->
deferred.reject(exception)
}
val promise = deferred.promise
promise.fail { exception ->
val path = if (guardSnode != null) paths.firstOrNull { it.contains(guardSnode!!) } else null
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.shared.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.shared.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 ->
@Suppress("NAME_SHADOWING") val exception = exception as? HTTPRequestFailedAtDestinationException ?: throw exception
throw SnodeAPI.shared.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)
}
}
/**
* 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
}

@ -1,95 +0,0 @@
package org.session.libsignal.service.loki.api.onionrequests
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.utilities.EncryptionResult
import org.session.libsignal.service.loki.api.utilities.EncryptionUtilities
import org.session.libsignal.utilities.ThreadUtils
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>()
ThreadUtils.queue {
try {
// Wrapping isn't needed for file server or open group onion requests
when (destination) {
is OnionRequestAPI.Destination.Snode -> {
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key
val payloadAsData = JsonUtil.toJson(payload).toByteArray()
val plaintext = encode(payloadAsData, mapOf( "headers" to "" ))
val result = EncryptionUtilities.encryptForX25519PublicKey(plaintext, snodeX25519PublicKey)
deferred.resolve(result)
}
is OnionRequestAPI.Destination.Server -> {
val plaintext = JsonUtil.toJson(payload).toByteArray()
val result = EncryptionUtilities.encryptForX25519PublicKey(plaintext, destination.x25519PublicKey)
deferred.resolve(result)
}
}
} catch (exception: Exception) {
deferred.reject(exception)
}
}
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>()
ThreadUtils.queue {
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 = EncryptionUtilities.encryptForX25519PublicKey(plaintext, x25519PublicKey)
deferred.resolve(result)
} catch (exception: Exception) {
deferred.reject(exception)
}
}
return deferred.promise
}
}

@ -1,88 +0,0 @@
package org.session.libsignal.service.loki.utilities
import okhttp3.HttpUrl
import okhttp3.Request
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.service.api.messages.SignalServiceAttachment
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
import org.session.libsignal.service.api.push.exceptions.PushNetworkException
import org.session.libsignal.utilities.Base64
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import java.io.*
object DownloadUtilities {
/**
* Blocks the calling thread.
*/
@JvmStatic
fun downloadFile(destination: File, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
val outputStream = FileOutputStream(destination) // Throws
var remainingAttempts = 4
var exception: Exception? = null
while (remainingAttempts > 0) {
remainingAttempts -= 1
try {
downloadFile(outputStream, url, maxSize, listener)
exception = null
break
} catch (e: Exception) {
exception = e
}
}
if (exception != null) { throw exception }
}
/**
* Blocks the calling thread.
*/
@JvmStatic
fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
// because the underlying Signal logic requires these to work correctly
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
var newPrefixedHost = oldPrefixedHost
if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) {
newPrefixedHost = FileServerAPI.shared.server
}
// Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM
// → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35
val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/")
val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID"
val request = Request.Builder().url(sanitizedURL).get()
try {
val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get()
val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get()
val result = json["result"] as? String
if (result == null) {
Log.d("Loki", "Couldn't parse attachment from: $json.")
throw PushNetworkException("Missing response body.")
}
val body = Base64.decode(result)
if (body.size > maxSize) {
Log.d("Loki", "Attachment size limit exceeded.")
throw PushNetworkException("Max response size exceeded.")
}
body.inputStream().use { input ->
val buffer = ByteArray(32768)
var count = 0
var bytes = input.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
count += bytes
if (count > maxSize) {
Log.d("Loki", "Attachment size limit exceeded.")
throw PushNetworkException("Max response size exceeded.")
}
listener?.onAttachmentProgress(body.size.toLong(), count.toLong())
bytes = input.read(buffer)
}
}
} catch (e: Exception) {
Log.d("Loki", "Couldn't download attachment due to error: $e.")
throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e)
}
}
}
Loading…
Cancel
Save