This commit is contained in:
Brice-W
2021-04-29 13:53:50 +10:00
193 changed files with 1341 additions and 17144 deletions

View File

@@ -1,4 +1,3 @@
all:
protoc25 --java_out=../src/main/java/ SignalService.proto WebSocketResources.proto
protoc25 --java_out=../src/main/java/ UnidentifiedDelivery.proto

View File

@@ -8,8 +8,8 @@ option java_outer_classname = "SignalServiceProtos";
message Envelope {
enum Type {
UNIDENTIFIED_SENDER = 6;
CLOSED_GROUP_CIPHERTEXT = 7;
SESSION_MESSAGE = 6;
CLOSED_GROUP_MESSAGE = 7;
}
// @required
@@ -17,39 +17,32 @@ message Envelope {
optional string source = 2;
optional uint32 sourceDevice = 7;
// @required
optional uint64 timestamp = 5;
required uint64 timestamp = 5;
optional bytes content = 8;
optional uint64 serverTimestamp = 10;
}
message TypingMessage {
enum Action {
STARTED = 0;
STOPPED = 1;
}
enum Action {
STARTED = 0;
STOPPED = 1;
}
// @required
optional uint64 timestamp = 1;
// @required
optional Action action = 2;
// @required
required uint64 timestamp = 1;
// @required
required Action action = 2;
}
message Content {
optional DataMessage dataMessage = 1;
optional ReceiptMessage receiptMessage = 5;
optional TypingMessage typingMessage = 6;
optional ConfigurationMessage configurationMessage = 7;
optional DataMessage dataMessage = 1;
optional ReceiptMessage receiptMessage = 5;
optional TypingMessage typingMessage = 6;
optional ConfigurationMessage configurationMessage = 7;
optional DataExtractionNotification dataExtractionNotification = 82;
}
message ClosedGroupCiphertextMessageWrapper {
// @required
optional bytes ciphertext = 1;
// @required
optional bytes ephemeralPublicKey = 2;
}
message KeyPair {
// @required
required bytes publicKey = 1;
@@ -90,87 +83,16 @@ message DataMessage {
}
// @required
optional uint64 id = 1;
required uint64 id = 1;
// @required
optional string author = 2;
required string author = 2;
optional string text = 3;
repeated QuotedAttachment attachments = 4;
}
message Contact {
message Name {
optional string givenName = 1;
optional string familyName = 2;
optional string prefix = 3;
optional string suffix = 4;
optional string middleName = 5;
optional string displayName = 6;
}
message Phone {
enum Type {
HOME = 1;
MOBILE = 2;
WORK = 3;
CUSTOM = 4;
}
optional string value = 1;
optional Type type = 2;
optional string label = 3;
}
message Email {
enum Type {
HOME = 1;
MOBILE = 2;
WORK = 3;
CUSTOM = 4;
}
optional string value = 1;
optional Type type = 2;
optional string label = 3;
}
message PostalAddress {
enum Type {
HOME = 1;
WORK = 2;
CUSTOM = 3;
}
optional Type type = 1;
optional string label = 2;
optional string street = 3;
optional string pobox = 4;
optional string neighborhood = 5;
optional string city = 6;
optional string region = 7;
optional string postcode = 8;
optional string country = 9;
}
message Avatar {
optional AttachmentPointer avatar = 1;
optional bool isProfile = 2;
}
optional Name name = 1;
repeated Phone number = 3;
repeated Email email = 4;
repeated PostalAddress address = 5;
optional Avatar avatar = 6;
optional string organization = 7;
}
message Preview {
// @required
optional string url = 1;
required string url = 1;
optional string title = 2;
optional AttachmentPointer image = 3;
}
@@ -184,13 +106,11 @@ message DataMessage {
enum Type {
NEW = 1; // publicKey, name, encryptionKeyPair, members, admins
UPDATE = 2; // name, members
ENCRYPTION_KEY_PAIR = 3; // publicKey, wrappers
NAME_CHANGE = 4; // name
MEMBERS_ADDED = 5; // members
MEMBERS_REMOVED = 6; // members
MEMBER_LEFT = 7;
ENCRYPTION_KEY_PAIR_REQUEST = 8;
}
message KeyPairWrapper {
@@ -218,12 +138,10 @@ message DataMessage {
optional bytes profileKey = 6;
optional uint64 timestamp = 7;
optional Quote quote = 8;
repeated Contact contact = 9;
repeated Preview preview = 10;
optional LokiProfile profile = 101;
optional ClosedGroupControlMessage closedGroupControlMessage = 104;
optional string syncTarget = 105;
optional PublicChatInfo publicChatInfo = 999;
}
message ConfigurationMessage {
@@ -261,7 +179,7 @@ message ReceiptMessage {
}
// @required
optional Type type = 1;
required Type type = 1;
repeated uint64 timestamp = 2;
}
@@ -272,7 +190,7 @@ message AttachmentPointer {
}
// @required
optional fixed64 id = 1;
required fixed64 id = 1;
optional string contentType = 2;
optional bytes key = 3;
optional uint32 size = 4;
@@ -304,51 +222,4 @@ message GroupContext {
repeated string members = 4;
optional AttachmentPointer avatar = 5;
repeated string admins = 6;
// Loki - These fields are only used internally for the Android code base.
// This is so that we can differentiate adding/kicking.
// DO NOT USE WHEN SENDING MESSAGES.
repeated string newMembers = 998;
repeated string removedMembers = 999;
}
message ContactDetails {
message Avatar {
optional string contentType = 1;
optional uint32 length = 2;
}
// @required
optional string number = 1;
optional string name = 2;
optional Avatar avatar = 3;
optional string color = 4;
optional bytes profileKey = 6;
optional bool blocked = 7;
optional uint32 expireTimer = 8;
optional string nickname = 101;
}
message GroupDetails {
message Avatar {
optional string contentType = 1;
optional uint32 length = 2;
}
// @required
optional bytes id = 1;
optional string name = 2;
repeated string members = 3;
optional Avatar avatar = 4;
optional bool active = 5 [default = true];
optional uint32 expireTimer = 6;
optional string color = 7;
optional bool blocked = 8;
repeated string admins = 9;
}
message PublicChatInfo { // Intended for internal use only
optional uint64 serverID = 1;
}

View File

@@ -1,40 +0,0 @@
syntax = "proto2";
package signal;
option java_package = "org.session.libsignal.metadata";
option java_outer_classname = "SignalProtos";
message ServerCertificate {
message Certificate {
optional uint32 id = 1;
optional bytes key = 2;
}
optional bytes certificate = 1;
optional bytes signature = 2;
}
message SenderCertificate {
optional string sender = 1;
optional uint32 senderDevice = 2;
}
message UnidentifiedSenderMessage {
message Message {
enum Type {
PREKEY_MESSAGE = 1;
MESSAGE = 2;
FALLBACK_MESSAGE = 3;
}
optional Type type = 1;
optional SenderCertificate senderCertificate = 2;
optional bytes content = 3;
}
optional bytes ephemeralPublic = 1;
optional bytes encryptedStatic = 2;
optional bytes encryptedMessage = 3;
}

View File

@@ -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();
}
}

View File

@@ -22,7 +22,6 @@ import org.session.libsignal.service.api.messages.SignalServiceEnvelope;
import org.session.libsignal.service.api.messages.SignalServiceGroup;
import org.session.libsignal.service.api.messages.SignalServiceReceiptMessage;
import org.session.libsignal.service.api.messages.SignalServiceTypingMessage;
import org.session.libsignal.service.api.messages.shared.SharedContact;
import org.session.libsignal.service.api.push.SignalServiceAddress;
import org.session.libsignal.service.internal.push.PushTransportDetails;
import org.session.libsignal.service.internal.push.SignalServiceProtos;
@@ -34,8 +33,9 @@ import org.session.libsignal.service.internal.push.SignalServiceProtos.ReceiptMe
import org.session.libsignal.service.internal.push.SignalServiceProtos.TypingMessage;
import org.session.libsignal.service.loki.api.crypto.SessionProtocol;
import org.session.libsignal.service.loki.api.crypto.SessionProtocolUtilities;
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol;
import org.session.libsignal.service.loki.LokiAPIDatabaseProtocol;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
@@ -153,7 +153,6 @@ public class SignalServiceCipher {
List<SignalServiceAttachment> attachments = new LinkedList<SignalServiceAttachment>();
boolean expirationUpdate = ((content.getFlags() & DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0);
SignalServiceDataMessage.Quote quote = createQuote(content);
List<SharedContact> sharedContacts = createSharedContacts(content);
List<Preview> previews = createPreviews(content);
ClosedGroupControlMessage closedGroupControlMessage = content.getClosedGroupControlMessage();
String syncTarget = content.getSyncTarget();
@@ -176,7 +175,7 @@ public class SignalServiceCipher {
expirationUpdate,
content.hasProfileKey() ? content.getProfileKey().toByteArray() : null,
quote,
sharedContacts,
new ArrayList<>(),
previews,
closedGroupControlMessage,
syncTarget);
@@ -245,101 +244,6 @@ public class SignalServiceCipher {
return results;
}
private List<SharedContact> createSharedContacts(DataMessage content) {
if (content.getContactCount() <= 0) return null;
List<SharedContact> results = new LinkedList<SharedContact>();
for (DataMessage.Contact contact : content.getContactList()) {
SharedContact.Builder builder = SharedContact.newBuilder()
.setName(SharedContact.Name.newBuilder()
.setDisplay(contact.getName().getDisplayName())
.setFamily(contact.getName().getFamilyName())
.setGiven(contact.getName().getGivenName())
.setMiddle(contact.getName().getMiddleName())
.setPrefix(contact.getName().getPrefix())
.setSuffix(contact.getName().getSuffix())
.build());
if (contact.getAddressCount() > 0) {
for (DataMessage.Contact.PostalAddress address : contact.getAddressList()) {
SharedContact.PostalAddress.Type type = SharedContact.PostalAddress.Type.HOME;
switch (address.getType()) {
case WORK: type = SharedContact.PostalAddress.Type.WORK; break;
case HOME: type = SharedContact.PostalAddress.Type.HOME; break;
case CUSTOM: type = SharedContact.PostalAddress.Type.CUSTOM; break;
}
builder.withAddress(SharedContact.PostalAddress.newBuilder()
.setCity(address.getCity())
.setCountry(address.getCountry())
.setLabel(address.getLabel())
.setNeighborhood(address.getNeighborhood())
.setPobox(address.getPobox())
.setPostcode(address.getPostcode())
.setRegion(address.getRegion())
.setStreet(address.getStreet())
.setType(type)
.build());
}
}
if (contact.getNumberCount() > 0) {
for (DataMessage.Contact.Phone phone : contact.getNumberList()) {
SharedContact.Phone.Type type = SharedContact.Phone.Type.HOME;
switch (phone.getType()) {
case HOME: type = SharedContact.Phone.Type.HOME; break;
case WORK: type = SharedContact.Phone.Type.WORK; break;
case MOBILE: type = SharedContact.Phone.Type.MOBILE; break;
case CUSTOM: type = SharedContact.Phone.Type.CUSTOM; break;
}
builder.withPhone(SharedContact.Phone.newBuilder()
.setLabel(phone.getLabel())
.setType(type)
.setValue(phone.getValue())
.build());
}
}
if (contact.getEmailCount() > 0) {
for (DataMessage.Contact.Email email : contact.getEmailList()) {
SharedContact.Email.Type type = SharedContact.Email.Type.HOME;
switch (email.getType()) {
case HOME: type = SharedContact.Email.Type.HOME; break;
case WORK: type = SharedContact.Email.Type.WORK; break;
case MOBILE: type = SharedContact.Email.Type.MOBILE; break;
case CUSTOM: type = SharedContact.Email.Type.CUSTOM; break;
}
builder.withEmail(SharedContact.Email.newBuilder()
.setLabel(email.getLabel())
.setType(type)
.setValue(email.getValue())
.build());
}
}
if (contact.hasAvatar()) {
builder.setAvatar(SharedContact.Avatar.newBuilder()
.withAttachment(createAttachmentPointer(contact.getAvatar().getAvatar()))
.withProfileFlag(contact.getAvatar().getIsProfile())
.build());
}
if (contact.hasOrganization()) {
builder.withOrganization(contact.getOrganization());
}
results.add(builder.build());
}
return results;
}
private SignalServiceAttachmentPointer createAttachmentPointer(AttachmentPointer pointer) {
return new SignalServiceAttachmentPointer(pointer.getId(),
pointer.getContentType(),

View File

@@ -10,7 +10,6 @@ import org.session.libsignal.libsignal.util.guava.Optional;
import org.session.libsignal.service.api.messages.shared.SharedContact;
import org.session.libsignal.service.api.push.SignalServiceAddress;
import org.session.libsignal.service.internal.push.SignalServiceProtos.DataMessage.ClosedGroupControlMessage;
import org.session.libsignal.service.loki.utilities.TTLUtilities;
import java.util.LinkedList;
import java.util.List;
@@ -247,7 +246,7 @@ public class SignalServiceDataMessage {
}
public int getTTL() {
return TTLUtilities.getTTL(TTLUtilities.MessageType.Regular);
return 0;
}
public static class Builder {

View File

@@ -133,10 +133,10 @@ public class SignalServiceEnvelope {
}
public boolean isUnidentifiedSender() {
return envelope.getType().getNumber() == Envelope.Type.UNIDENTIFIED_SENDER_VALUE;
return envelope.getType().getNumber() == Envelope.Type.SESSION_MESSAGE_VALUE;
}
public boolean isClosedGroupCiphertext() {
return envelope.getType().getNumber() == Envelope.Type.CLOSED_GROUP_CIPHERTEXT_VALUE;
return envelope.getType().getNumber() == Envelope.Type.CLOSED_GROUP_MESSAGE_VALUE;
}
}

View File

@@ -1,8 +1,6 @@
package org.session.libsignal.service.api.messages;
import org.session.libsignal.service.loki.utilities.TTLUtilities;
import java.util.List;
public class SignalServiceReceiptMessage {
@@ -41,5 +39,5 @@ public class SignalServiceReceiptMessage {
return type == Type.READ;
}
public int getTTL() { return TTLUtilities.getTTL(TTLUtilities.MessageType.Receipt); }
public int getTTL() { return 0; }
}

View File

@@ -1,7 +1,5 @@
package org.session.libsignal.service.api.messages;
import org.session.libsignal.service.loki.utilities.TTLUtilities;
public class SignalServiceTypingMessage {
public enum Action {
@@ -32,5 +30,5 @@ public class SignalServiceTypingMessage {
return action == Action.STOPPED;
}
public int getTTL() { return TTLUtilities.getTTL(TTLUtilities.MessageType.TypingIndicator); }
public int getTTL() { return 0; }
}

View File

@@ -1,6 +1,5 @@
package org.session.libsignal.service.api.messages.shared;
import org.session.libsignal.libsignal.util.guava.Optional;
import org.session.libsignal.service.api.messages.SignalServiceAttachment;

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.utilities
package org.session.libsignal.service.loki
interface Broadcaster {

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.api.utilities
package org.session.libsignal.service.loki
import okhttp3.*
import org.session.libsignal.utilities.logging.Log

View File

@@ -1,7 +1,7 @@
package org.session.libsignal.service.loki.database
package org.session.libsignal.service.loki
import org.session.libsignal.libsignal.ecc.ECKeyPair
import org.session.libsignal.service.loki.api.Snode
import org.session.libsignal.service.loki.Snode
import java.util.*
interface LokiAPIDatabaseProtocol {

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.database
package org.session.libsignal.service.loki
interface LokiMessageDatabaseProtocol {

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.database
package org.session.libsignal.service.loki
interface LokiOpenGroupDatabaseProtocol {

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.database
package org.session.libsignal.service.loki
interface LokiUserDatabaseProtocol {

View File

@@ -1,3 +1,3 @@
package org.session.libsignal.service.loki.utilities.mentions
package org.session.libsignal.service.loki
data class Mention(val publicKey: String, val displayName: String)

View File

@@ -1,6 +1,5 @@
package org.session.libsignal.service.loki.crypto
package org.session.libsignal.service.loki
import java.io.File
import java.util.zip.CRC32
/**
@@ -95,8 +94,10 @@ class MnemonicCodec(private val loadFileContents: (String) -> String) {
var result = ""
val n = truncatedWordSet.size.toLong()
// Check preconditions
if (words.size < 12) { throw DecodingError.InputTooShort }
if (words.size % 3 == 0) { throw DecodingError.MissingLastWord }
if (words.size < 12) { throw DecodingError.InputTooShort
}
if (words.size % 3 == 0) { throw DecodingError.MissingLastWord
}
// Get checksum word
val checksumWord = words.removeAt(words.lastIndex)
// Decode
@@ -106,7 +107,8 @@ class MnemonicCodec(private val loadFileContents: (String) -> String) {
val w2 = truncatedWordSet.indexOf(words[chunkStartIndex + 1].substring(0 until prefixLength))
val w3 = truncatedWordSet.indexOf(words[chunkStartIndex + 2].substring(0 until prefixLength))
val x = w1 + n * ((n - w1 + w2) % n) + n * n * ((n - w2 + w3) % n)
if (x % n != w1.toLong()) { throw DecodingError.Generic }
if (x % n != w1.toLong()) { throw DecodingError.Generic
}
val string = "0000000" + x.toString(16)
result += swap(string.substring(string.length - 8 until string.length))
} catch (e: Exception) {
@@ -116,7 +118,8 @@ class MnemonicCodec(private val loadFileContents: (String) -> String) {
// Verify checksum
val checksumIndex = determineChecksumIndex(words, prefixLength)
val expectedChecksumWord = words[checksumIndex]
if (expectedChecksumWord.substring(0 until prefixLength) != checksumWord.substring(0 until prefixLength)) { throw DecodingError.VerificationFailed }
if (expectedChecksumWord.substring(0 until prefixLength) != checksumWord.substring(0 until prefixLength)) { throw DecodingError.VerificationFailed
}
// Return
return result
}

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.utilities
package org.session.libsignal.service.loki
import org.session.libsignal.service.api.crypto.DigestingOutputStream
import org.session.libsignal.service.internal.push.http.OutputStreamFactory

View File

@@ -1,7 +1,7 @@
package org.session.libsignal.service.loki.api.crypto
import org.session.libsignal.libsignal.ecc.ECKeyPair
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
import org.session.libsignal.service.loki.LokiAPIDatabaseProtocol
interface SessionProtocol {

View File

@@ -1,4 +1,4 @@
package org.session.libsignal.service.loki.api
package org.session.libsignal.service.loki
import org.session.libsignal.service.internal.push.SignalServiceProtos

View File

@@ -1,17 +1,10 @@
package org.session.libsignal.service.loki.api
public class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
package org.session.libsignal.service.loki
class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
val ip: String get() = address.removePrefix("https://")
enum class Method(val rawValue: String) {
/**
* Only supported by snode targets.
*/
public enum class Method(val rawValue: String) {
GetSwarm("get_snodes_for_pubkey"),
/**
* Only supported by snode targets.
*/
GetMessages("retrieve"),
SendMessage("store")
}

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -1,84 +0,0 @@
package org.session.libsignal.service.loki.api
import com.google.protobuf.ByteString
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.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(message: SignalMessageInfo): ByteArray {
try {
val envelope = createEnvelope(message)
val webSocketMessage = createWebSocketMessage(envelope)
return webSocketMessage.toByteArray()
} catch (e: Exception) {
throw if (e is Error) { e } else { Error.FailedToWrapData }
}
}
private fun createEnvelope(message: SignalMessageInfo): Envelope {
try {
val builder = Envelope.newBuilder()
builder.type = message.type
builder.timestamp = message.timestamp
builder.source = message.senderPublicKey
builder.sourceDevice = message.senderDeviceID
builder.content = ByteString.copyFrom(Base64.decode(message.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

@@ -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.")
}
}
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -1,37 +0,0 @@
package org.session.libsignal.service.loki.api.opengroups
import org.session.libsignal.utilities.JsonUtil
public data class PublicChat(
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): PublicChat? {
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 PublicChat(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

@@ -1,7 +0,0 @@
package org.session.libsignal.service.loki.api.opengroups
public data class PublicChatInfo (
public val displayName: String,
public val profilePictureURL: String,
public val memberCount: Int
)

View File

@@ -1,178 +0,0 @@
package org.session.libsignal.service.loki.api.opengroups
import org.whispersystems.curve25519.Curve25519
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.Hex
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
public data class PublicChatMessage(
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 {
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): PublicChatMessage? {
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,19 +0,0 @@
package org.session.libsignal.service.loki.api.utilities
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
internal object DecryptionUtilities {
/**
* Sync. Don't call from the main thread.
*/
internal fun decryptUsingAESGCM(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray {
val iv = ivAndCiphertext.sliceArray(0 until EncryptionUtilities.ivSize)
val ciphertext = ivAndCiphertext.sliceArray(EncryptionUtilities.ivSize until ivAndCiphertext.count())
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(EncryptionUtilities.gcmTagSize, iv))
return cipher.doFinal(ciphertext)
}
}

View File

@@ -1,45 +0,0 @@
package org.session.libsignal.service.loki.api.utilities
import org.whispersystems.curve25519.Curve25519
import org.session.libsignal.libsignal.util.ByteUtil
import org.session.libsignal.utilities.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 data class EncryptionResult(
internal val ciphertext: ByteArray,
internal val symmetricKey: ByteArray,
internal val ephemeralPublicKey: ByteArray
)
internal object EncryptionUtilities {
internal val gcmTagSize = 128
internal val ivSize = 12
/**
* Sync. Don't call from the main thread.
*/
internal fun encryptUsingAESGCM(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 encryptForX25519PublicKey(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 = encryptUsingAESGCM(plaintext, symmetricKey)
return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey)
}
}

View File

@@ -1,49 +0,0 @@
package org.session.libsignal.service.loki.api.utilities
import okhttp3.MultipartBody
import okhttp3.Request
import okio.Buffer
import org.session.libsignal.utilities.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

@@ -1,11 +0,0 @@
package org.session.libsignal.service.loki.database
import org.session.libsignal.service.loki.api.opengroups.PublicChat
interface LokiThreadDatabaseProtocol {
fun getThreadID(publicKey: String): Long
fun getPublicChat(threadID: Long): PublicChat?
fun setPublicChat(publicChat: PublicChat, threadID: Long)
fun removePublicChat(threadID: Long)
}

View File

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

View File

@@ -1,38 +0,0 @@
package org.session.libsignal.service.loki.utilities
public object TTLUtilities {
/**
* If a message type specifies an invalid TTL, this will be used.
*/
public val fallbackMessageTTL = 2 * 24 * 60 * 60 * 1000
public enum class MessageType {
// Unimportant control messages
Address, Call, TypingIndicator, Verified,
// Somewhat important control messages
DeviceLink,
// Important control messages
ClosedGroupUpdate, Ephemeral, SessionRequest, Receipt, Sync, DeviceUnlinkingRequest,
// Visible messages
Regular
}
@JvmStatic
public fun getTTL(messageType: MessageType): Int {
val minuteInMs = 60 * 1000
val hourInMs = 60 * minuteInMs
val dayInMs = 24 * hourInMs
return when (messageType) {
// Unimportant control messages
MessageType.Address, MessageType.Call, MessageType.TypingIndicator, MessageType.Verified -> 20 * 1000
// Somewhat important control messages
MessageType.DeviceLink -> 1 * hourInMs
// Important control messages
MessageType.ClosedGroupUpdate, MessageType.Ephemeral, MessageType.SessionRequest, MessageType.Receipt,
MessageType.Sync, MessageType.DeviceUnlinkingRequest -> 2 * dayInMs - 1 * hourInMs
// Visible messages
MessageType.Regular -> 2 * dayInMs
}
}
}

View File

@@ -1,57 +0,0 @@
package org.session.libsignal.service.loki.utilities.mentions
import org.session.libsignal.service.loki.database.LokiThreadDatabaseProtocol
import org.session.libsignal.service.loki.database.LokiUserDatabaseProtocol
class MentionsManager(private val userPublicKey: String, private val threadDatabase: LokiThreadDatabaseProtocol,
private val userDatabase: LokiUserDatabaseProtocol) {
var userPublicKeyCache = mutableMapOf<Long, Set<String>>() // Thread ID to set of user hex encoded public keys
companion object {
public lateinit var shared: MentionsManager
public fun configureIfNeeded(userPublicKey: String, threadDatabase: LokiThreadDatabaseProtocol, userDatabase: LokiUserDatabaseProtocol) {
if (::shared.isInitialized) { return; }
shared = MentionsManager(userPublicKey, threadDatabase, userDatabase)
}
}
fun cache(publicKey: String, threadID: Long) {
val cache = userPublicKeyCache[threadID]
if (cache != null) {
userPublicKeyCache[threadID] = cache.plus(publicKey)
} else {
userPublicKeyCache[threadID] = setOf( publicKey )
}
}
fun getMentionCandidates(query: String, threadID: Long): List<Mention> {
// Prepare
val cache = userPublicKeyCache[threadID] ?: return listOf()
// Gather candidates
val publicChat = threadDatabase.getPublicChat(threadID)
var candidates: List<Mention> = cache.mapNotNull { publicKey ->
val displayName: String?
if (publicChat != null) {
displayName = userDatabase.getServerDisplayName(publicChat.id, publicKey)
} else {
displayName = userDatabase.getDisplayName(publicKey)
}
if (displayName == null) { return@mapNotNull null }
if (displayName.startsWith("Anonymous")) { return@mapNotNull null }
Mention(publicKey, displayName)
}
candidates = candidates.filter { it.publicKey != userPublicKey }
// Sort alphabetically first
candidates.sortedBy { it.displayName }
if (query.length >= 2) {
// Filter out any non-matching candidates
candidates = candidates.filter { it.displayName.toLowerCase().contains(query.toLowerCase()) }
// Sort based on where in the candidate the query occurs
candidates.sortedBy { it.displayName.toLowerCase().indexOf(query.toLowerCase()) }
}
// Return
return candidates
}
}

View File

@@ -58,7 +58,7 @@ public class JsonUtil {
return objectMapper.writeValueAsString(object);
}
public static String toJson(Object object) {
public static String toJson(Object object) {
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
@@ -67,32 +67,11 @@ public class JsonUtil {
}
}
public static class IdentityKeySerializer extends JsonSerializer<IdentityKey> {
@Override
public void serialize(IdentityKey value, JsonGenerator gen, SerializerProvider serializers)
throws IOException
{
gen.writeString(Base64.encodeBytesWithoutPadding(value.serialize()));
}
}
public static class IdentityKeyDeserializer extends JsonDeserializer<IdentityKey> {
@Override
public IdentityKey deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
try {
return new IdentityKey(Base64.decodeWithoutPadding(p.getValueAsString()), 0);
} catch (InvalidKeyException e) {
throw new IOException(e);
}
}
}
public static ObjectMapper getMapper() {
return objectMapper;
}
public static class SaneJSONObject {
private final JSONObject delegate;
public SaneJSONObject(JSONObject delegate) {

View File

@@ -1,25 +1,11 @@
@file:JvmName("PromiseUtilities")
package org.session.libsignal.utilities
import nl.komponents.kovenant.Context
import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.jvm.asDispatcher
import org.session.libsignal.utilities.logging.Log
import java.util.concurrent.Executors
import java.util.concurrent.TimeoutException
fun Kovenant.createContext(): Context {
return createContext {
callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher()
workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher()
multipleCompletion = { v1, v2 ->
Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.")
}
}
}
fun <V, E : Throwable> Promise<V, E>.get(defaultValue: V): V {
return try {
get()

View File

@@ -3,7 +3,6 @@ package org.session.libsignal.utilities
import java.util.concurrent.*
object ThreadUtils {
val executorPool = Executors.newCachedThreadPool()
@JvmStatic
@@ -17,10 +16,8 @@ object ThreadUtils {
@JvmStatic
fun newDynamicSingleThreadedExecutor(): ExecutorService {
val executor = ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS,
LinkedBlockingQueue())
val executor = ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, LinkedBlockingQueue())
executor.allowCoreThreadTimeOut(true)
return executor
}
}