mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-11 15:07:46 +00:00
Merge branch 'dev' of https://github.com/loki-project/session-android into media-saving
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
all:
|
||||
protoc25 --java_out=../src/main/java/ SignalService.proto WebSocketResources.proto
|
||||
protoc25 --java_out=../src/main/java/ UnidentifiedDelivery.proto
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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(),
|
||||
|
@@ -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 {
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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; }
|
||||
}
|
||||
|
@@ -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; }
|
||||
}
|
||||
|
@@ -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;
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
package org.session.libsignal.service.loki.utilities
|
||||
package org.session.libsignal.service.loki
|
||||
|
||||
interface Broadcaster {
|
||||
|
@@ -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
|
@@ -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 {
|
@@ -1,4 +1,4 @@
|
||||
package org.session.libsignal.service.loki.database
|
||||
package org.session.libsignal.service.loki
|
||||
|
||||
interface LokiMessageDatabaseProtocol {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package org.session.libsignal.service.loki.database
|
||||
package org.session.libsignal.service.loki
|
||||
|
||||
interface LokiOpenGroupDatabaseProtocol {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package org.session.libsignal.service.loki.database
|
||||
package org.session.libsignal.service.loki
|
||||
|
||||
interface LokiUserDatabaseProtocol {
|
||||
|
@@ -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)
|
@@ -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
|
||||
}
|
@@ -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
|
@@ -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 {
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
}
|
@@ -1,252 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import nl.komponents.kovenant.then
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.DiffieHellman
|
||||
import org.session.libsignal.service.api.crypto.ProfileCipherOutputStream
|
||||
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.session.libsignal.service.api.push.exceptions.PushNetworkException
|
||||
import org.session.libsignal.service.api.util.StreamDetails
|
||||
import org.session.libsignal.service.internal.push.ProfileAvatarData
|
||||
import org.session.libsignal.service.internal.push.PushAttachmentData
|
||||
import org.session.libsignal.service.internal.push.http.DigestingRequestBody
|
||||
import org.session.libsignal.service.internal.push.http.ProfileCipherOutputStreamFactory
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
import org.session.libsignal.utilities.recover
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Base class that provides utilities for .NET based APIs.
|
||||
*/
|
||||
open class LokiDotNetAPI(internal val userPublicKey: String, private val userPrivateKey: ByteArray, private val apiDatabase: LokiAPIDatabaseProtocol) {
|
||||
|
||||
internal enum class HTTPVerb { GET, PUT, POST, DELETE, PATCH }
|
||||
|
||||
companion object {
|
||||
private val authTokenRequestCache = hashMapOf<String, Promise<String, Exception>>()
|
||||
}
|
||||
|
||||
public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)
|
||||
|
||||
public fun getAuthToken(server: String): Promise<String, Exception> {
|
||||
val token = apiDatabase.getAuthToken(server)
|
||||
if (token != null) { return Promise.of(token) }
|
||||
// Avoid multiple token requests to the server by caching
|
||||
var promise = authTokenRequestCache[server]
|
||||
if (promise == null) {
|
||||
promise = requestNewAuthToken(server).bind { submitAuthToken(it, server) }.then { newToken ->
|
||||
apiDatabase.setAuthToken(server, newToken)
|
||||
newToken
|
||||
}.always {
|
||||
authTokenRequestCache.remove(server)
|
||||
}
|
||||
authTokenRequestCache[server] = promise
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private fun requestNewAuthToken(server: String): Promise<String, Exception> {
|
||||
Log.d("Loki", "Requesting auth token for server: $server.")
|
||||
val parameters: Map<String, Any> = mapOf( "pubKey" to userPublicKey )
|
||||
return execute(HTTPVerb.GET, server, "loki/v1/get_challenge", false, parameters).map(SnodeAPI.sharedContext) { json ->
|
||||
try {
|
||||
val base64EncodedChallenge = json["cipherText64"] as String
|
||||
val challenge = Base64.decode(base64EncodedChallenge)
|
||||
val base64EncodedServerPublicKey = json["serverPubKey64"] as String
|
||||
var serverPublicKey = Base64.decode(base64EncodedServerPublicKey)
|
||||
// Discard the "05" prefix if needed
|
||||
if (serverPublicKey.count() == 33) {
|
||||
val hexEncodedServerPublicKey = Hex.toStringCondensed(serverPublicKey)
|
||||
serverPublicKey = Hex.fromStringCondensed(hexEncodedServerPublicKey.removing05PrefixIfNeeded())
|
||||
}
|
||||
// The challenge is prefixed by the 16 bit IV
|
||||
val tokenAsData = DiffieHellman.decrypt(challenge, serverPublicKey, userPrivateKey)
|
||||
val token = tokenAsData.toString(Charsets.UTF_8)
|
||||
token
|
||||
} catch (exception: Exception) {
|
||||
Log.d("Loki", "Couldn't parse auth token for server: $server.")
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitAuthToken(token: String, server: String): Promise<String, Exception> {
|
||||
Log.d("Loki", "Submitting auth token for server: $server.")
|
||||
val parameters = mapOf( "pubKey" to userPublicKey, "token" to token )
|
||||
return execute(HTTPVerb.POST, server, "loki/v1/submit_challenge", false, parameters, isJSONRequired = false).map { token }
|
||||
}
|
||||
|
||||
internal fun execute(verb: HTTPVerb, server: String, endpoint: String, isAuthRequired: Boolean = true, parameters: Map<String, Any> = mapOf(), isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||
fun execute(token: String?): Promise<Map<*, *>, Exception> {
|
||||
val sanitizedEndpoint = endpoint.removePrefix("/")
|
||||
var url = "$server/$sanitizedEndpoint"
|
||||
if (verb == HTTPVerb.GET || verb == HTTPVerb.DELETE) {
|
||||
val queryParameters = parameters.map { "${it.key}=${it.value}" }.joinToString("&")
|
||||
if (queryParameters.isNotEmpty()) { url += "?$queryParameters" }
|
||||
}
|
||||
var request = Request.Builder().url(url)
|
||||
if (isAuthRequired) {
|
||||
if (token == null) { throw IllegalStateException() }
|
||||
request = request.header("Authorization", "Bearer $token")
|
||||
}
|
||||
when (verb) {
|
||||
HTTPVerb.GET -> request = request.get()
|
||||
HTTPVerb.DELETE -> request = request.delete()
|
||||
else -> {
|
||||
val parametersAsJSON = JsonUtil.toJson(parameters)
|
||||
val body = RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
|
||||
when (verb) {
|
||||
HTTPVerb.PUT -> request = request.put(body)
|
||||
HTTPVerb.POST -> request = request.post(body)
|
||||
HTTPVerb.PATCH -> request = request.patch(body)
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
}
|
||||
val serverPublicKeyPromise = if (server == FileServerAPI.shared.server) Promise.of(FileServerAPI.fileServerPublicKey)
|
||||
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(server)
|
||||
return serverPublicKeyPromise.bind { serverPublicKey ->
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, isJSONRequired = isJSONRequired).recover { exception ->
|
||||
if (exception is HTTP.HTTPRequestFailedException) {
|
||||
val statusCode = exception.statusCode
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
apiDatabase.setAuthToken(server, null)
|
||||
throw SnodeAPI.Error.TokenExpired
|
||||
}
|
||||
}
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (isAuthRequired) {
|
||||
getAuthToken(server).bind { execute(it) }
|
||||
} else {
|
||||
execute(null)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getUserProfiles(publicKeys: Set<String>, server: String, includeAnnotations: Boolean): Promise<List<Map<*, *>>, Exception> {
|
||||
val parameters = mapOf( "include_user_annotations" to includeAnnotations.toInt(), "ids" to publicKeys.joinToString { "@$it" } )
|
||||
return execute(HTTPVerb.GET, server, "users", parameters = parameters).map { json ->
|
||||
val data = json["data"] as? List<Map<*, *>>
|
||||
if (data == null) {
|
||||
Log.d("Loki", "Couldn't parse user profiles for: $publicKeys from: $json.")
|
||||
throw SnodeAPI.Error.ParsingFailed
|
||||
}
|
||||
data!! // For some reason the compiler can't infer that this can't be null at this point
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setSelfAnnotation(server: String, type: String, newValue: Any?): Promise<Map<*, *>, Exception> {
|
||||
val annotation = mutableMapOf<String, Any>( "type" to type )
|
||||
if (newValue != null) { annotation["value"] = newValue }
|
||||
val parameters = mapOf( "annotations" to listOf( annotation ) )
|
||||
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters)
|
||||
}
|
||||
|
||||
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||
fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult {
|
||||
// This function mimics what Signal does in PushServiceSocket
|
||||
val contentType = "application/octet-stream"
|
||||
val file = DigestingRequestBody(attachment.data, attachment.outputStreamFactory, contentType, attachment.dataSize, attachment.listener)
|
||||
Log.d("Loki", "File size: ${attachment.dataSize} bytes.")
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("type", "network.loki")
|
||||
.addFormDataPart("Content-Type", contentType)
|
||||
.addFormDataPart("content", UUID.randomUUID().toString(), file)
|
||||
.build()
|
||||
val request = Request.Builder().url("$server/files").post(body)
|
||||
return upload(server, request) { json -> // Retrying is handled by AttachmentUploadJob
|
||||
val data = json["data"] as? Map<*, *>
|
||||
if (data == null) {
|
||||
Log.d("Loki", "Couldn't parse attachment from: $json.")
|
||||
throw SnodeAPI.Error.ParsingFailed
|
||||
}
|
||||
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
|
||||
val url = data["url"] as? String
|
||||
if (id == null || url == null || url.isEmpty()) {
|
||||
Log.d("Loki", "Couldn't parse upload from: $json.")
|
||||
throw SnodeAPI.Error.ParsingFailed
|
||||
}
|
||||
UploadResult(id, url, file.transmittedDigest)
|
||||
}.get()
|
||||
}
|
||||
|
||||
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||
fun uploadProfilePicture(server: String, key: ByteArray, profilePicture: StreamDetails, setLastProfilePictureUpload: () -> Unit): UploadResult {
|
||||
val profilePictureUploadData = ProfileAvatarData(profilePicture.stream, ProfileCipherOutputStream.getCiphertextLength(profilePicture.length), profilePicture.contentType, ProfileCipherOutputStreamFactory(key))
|
||||
val file = DigestingRequestBody(profilePictureUploadData.data, profilePictureUploadData.outputStreamFactory,
|
||||
profilePictureUploadData.contentType, profilePictureUploadData.dataLength, null)
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("type", "network.loki")
|
||||
.addFormDataPart("Content-Type", "application/octet-stream")
|
||||
.addFormDataPart("content", UUID.randomUUID().toString(), file)
|
||||
.build()
|
||||
val request = Request.Builder().url("$server/files").post(body)
|
||||
return retryIfNeeded(4) {
|
||||
upload(server, request) { json ->
|
||||
val data = json["data"] as? Map<*, *>
|
||||
if (data == null) {
|
||||
Log.d("Loki", "Couldn't parse profile picture from: $json.")
|
||||
throw SnodeAPI.Error.ParsingFailed
|
||||
}
|
||||
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
|
||||
val url = data["url"] as? String
|
||||
if (id == null || url == null || url.isEmpty()) {
|
||||
Log.d("Loki", "Couldn't parse profile picture from: $json.")
|
||||
throw SnodeAPI.Error.ParsingFailed
|
||||
}
|
||||
setLastProfilePictureUpload()
|
||||
UploadResult(id, url, file.transmittedDigest)
|
||||
}
|
||||
}.get()
|
||||
}
|
||||
|
||||
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||
private fun upload(server: String, request: Request.Builder, parse: (Map<*, *>) -> UploadResult): Promise<UploadResult, Exception> {
|
||||
val promise: Promise<Map<*, *>, Exception>
|
||||
if (server == FileServerAPI.shared.server) {
|
||||
request.addHeader("Authorization", "Bearer loki")
|
||||
// Uploads to the Loki File Server shouldn't include any personally identifiable information, so use a dummy auth token
|
||||
promise = OnionRequestAPI.sendOnionRequest(request.build(), FileServerAPI.shared.server, FileServerAPI.fileServerPublicKey)
|
||||
} else {
|
||||
promise = FileServerAPI.shared.getPublicKeyForOpenGroupServer(server).bind { openGroupServerPublicKey ->
|
||||
getAuthToken(server).bind { token ->
|
||||
request.addHeader("Authorization", "Bearer $token")
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, openGroupServerPublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return promise.map { json ->
|
||||
parse(json)
|
||||
}.recover { exception ->
|
||||
if (exception is HTTP.HTTPRequestFailedException) {
|
||||
val statusCode = exception.statusCode
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
apiDatabase.setAuthToken(server, null)
|
||||
}
|
||||
throw NonSuccessfulResponseCodeException("Request returned with status code ${exception.statusCode}.")
|
||||
}
|
||||
throw PushNetworkException(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Boolean.toInt(): Int { return if (this) 1 else 0 }
|
@@ -1,86 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
|
||||
import org.session.libsignal.service.loki.utilities.TTLUtilities
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.session.libsignal.service.loki.utilities.prettifiedDescription
|
||||
|
||||
internal data class LokiMessage(
|
||||
/**
|
||||
* The hex encoded public key of the receiver.
|
||||
*/
|
||||
internal val recipientPublicKey: String,
|
||||
/**
|
||||
* The content of the message.
|
||||
*/
|
||||
internal val data: String,
|
||||
/**
|
||||
* The time to live for the message in milliseconds.
|
||||
*/
|
||||
internal val ttl: Int,
|
||||
/**
|
||||
* Whether this message is a ping.
|
||||
*/
|
||||
internal val isPing: Boolean,
|
||||
/**
|
||||
* When the proof of work was calculated, if applicable (P2P messages don't require proof of work).
|
||||
*
|
||||
* - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970.
|
||||
*/
|
||||
internal var timestamp: Long? = null,
|
||||
/**
|
||||
* The base 64 encoded proof of work, if applicable (P2P messages don't require proof of work).
|
||||
*/
|
||||
internal var nonce: String? = null
|
||||
) {
|
||||
|
||||
internal companion object {
|
||||
|
||||
internal fun from(message: SignalMessageInfo): LokiMessage? {
|
||||
try {
|
||||
val wrappedMessage = MessageWrapper.wrap(message)
|
||||
val data = Base64.encodeBytes(wrappedMessage)
|
||||
val destination = message.recipientPublicKey
|
||||
var ttl = TTLUtilities.fallbackMessageTTL
|
||||
val messageTTL = message.ttl
|
||||
if (messageTTL != null && messageTTL != 0) { ttl = messageTTL }
|
||||
val isPing = message.isPing
|
||||
return LokiMessage(destination, data, ttl, isPing)
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to convert Signal message to Loki message: ${message.prettifiedDescription()}.")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
internal fun calculatePoW(): Promise<LokiMessage, Exception> {
|
||||
val deferred = deferred<LokiMessage, Exception>()
|
||||
// Run PoW in a background thread
|
||||
ThreadUtils.queue {
|
||||
val now = System.currentTimeMillis()
|
||||
val nonce = ProofOfWork.calculate(data, recipientPublicKey, now, ttl)
|
||||
if (nonce != null ) {
|
||||
deferred.resolve(copy(nonce = nonce, timestamp = now))
|
||||
} else {
|
||||
deferred.reject(SnodeAPI.Error.ProofOfWorkCalculationFailed)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
internal fun toJSON(): Map<String, String> {
|
||||
val result = mutableMapOf( "pubKey" to recipientPublicKey, "data" to data, "ttl" to ttl.toString() )
|
||||
val timestamp = timestamp
|
||||
val nonce = nonce
|
||||
if (timestamp != null && nonce != null) {
|
||||
result["timestamp"] = timestamp.toString()
|
||||
result["nonce"] = nonce
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
@@ -1,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
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api
|
||||
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.*
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
|
||||
public class PushNotificationAPI private constructor(public val server: String) {
|
||||
|
||||
companion object {
|
||||
private val maxRetryCount = 4
|
||||
public val pnServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
|
||||
|
||||
lateinit var shared: PushNotificationAPI
|
||||
|
||||
public fun configureIfNeeded(isDebugMode: Boolean) {
|
||||
if (::shared.isInitialized) { return; }
|
||||
val server = if (isDebugMode) "https://live.apns.getsession.org" else "https://live.apns.getsession.org"
|
||||
shared = PushNotificationAPI(server)
|
||||
}
|
||||
}
|
||||
|
||||
public fun notify(messageInfo: SignalMessageInfo) {
|
||||
val message = LokiMessage.from(messageInfo) ?: return
|
||||
val parameters = mapOf( "data" to message.data, "send_to" to message.recipientPublicKey )
|
||||
val url = "${server}/notify"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.pnServerPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code == null || code == 0) {
|
||||
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: $exception.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,280 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api
|
||||
|
||||
import nl.komponents.kovenant.Kovenant
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import nl.komponents.kovenant.task
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
import org.session.libsignal.utilities.*
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class SnodeAPI private constructor(public var userPublicKey: String, public val database: LokiAPIDatabaseProtocol, public val broadcaster: Broadcaster) {
|
||||
|
||||
companion object {
|
||||
val messageSendingContext = Kovenant.createContext()
|
||||
val messagePollingContext = Kovenant.createContext()
|
||||
/**
|
||||
* For operations that are shared between message sending and message polling.
|
||||
*/
|
||||
val sharedContext = Kovenant.createContext()
|
||||
|
||||
// region Initialization
|
||||
lateinit var shared: SnodeAPI
|
||||
|
||||
fun configureIfNeeded(userHexEncodedPublicKey: String, database: LokiAPIDatabaseProtocol, broadcaster: Broadcaster) {
|
||||
if (::shared.isInitialized) { return; }
|
||||
shared = SnodeAPI(userHexEncodedPublicKey, database, broadcaster)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Settings
|
||||
private val maxRetryCount = 6
|
||||
private val useOnionRequests = true
|
||||
|
||||
internal var powDifficulty = 1
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Error
|
||||
sealed class Error(val description: String) : Exception() {
|
||||
class HTTPRequestFailed(val code: Int) : Error("HTTP request failed with error code: $code.")
|
||||
object Generic : Error("An error occurred.")
|
||||
object ResponseBodyMissing: Error("Response body missing.")
|
||||
object MessageSigningFailed: Error("Failed to sign message.")
|
||||
/**
|
||||
* Only applicable to snode targets as proof of work isn't required for P2P messaging.
|
||||
*/
|
||||
object ProofOfWorkCalculationFailed : Error("Failed to calculate proof of work.")
|
||||
object MessageConversionFailed : Error("Failed to convert Signal message to Loki message.")
|
||||
object ClockOutOfSync : Error("The user's clock is out of sync with the service node network.")
|
||||
object SnodeMigrated : Error("The snode previously associated with the given public key has migrated to a different swarm.")
|
||||
object InsufficientProofOfWork : Error("The proof of work is insufficient.")
|
||||
object TokenExpired : Error("The auth token being used has expired.")
|
||||
object ParsingFailed : Error("Couldn't parse JSON.")
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Internal API
|
||||
/**
|
||||
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
|
||||
*/
|
||||
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String, parameters: Map<String, String>): RawResponsePromise {
|
||||
val url = "${snode.address}:${snode.port}/storage_rpc/v1"
|
||||
if (useOnionRequests) {
|
||||
return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey)
|
||||
} else {
|
||||
val deferred = deferred<Map<*, *>, Exception>()
|
||||
ThreadUtils.queue {
|
||||
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, payload)
|
||||
deferred.resolve(json)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is ConnectException || exception is SocketTimeoutException) {
|
||||
dropSnodeIfNeeded(snode, publicKey)
|
||||
} else {
|
||||
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
|
||||
if (httpRequestFailedException != null) {
|
||||
@Suppress("NAME_SHADOWING") val exception = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
|
||||
return@queue deferred.reject(exception)
|
||||
}
|
||||
Log.d("Loki", "Unhandled exception: $exception.")
|
||||
}
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
}
|
||||
|
||||
public fun getRawMessages(snode: Snode, publicKey: String): RawResponsePromise {
|
||||
val lastHashValue = database.getLastMessageHashValue(snode, publicKey) ?: ""
|
||||
val parameters = mapOf( "pubKey" to publicKey, "lastHash" to lastHashValue )
|
||||
return invoke(Snode.Method.GetMessages, snode, publicKey, parameters)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Public API
|
||||
fun getMessages(publicKey: String): MessageListPromise {
|
||||
return retryIfNeeded(maxRetryCount) {
|
||||
SwarmAPI.shared.getSingleTargetSnode(publicKey).bind(messagePollingContext) { snode ->
|
||||
getRawMessages(snode, publicKey).map(messagePollingContext) { parseRawMessagesResponse(it, snode, publicKey) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
fun sendSignalMessage(message: SignalMessageInfo): Promise<Set<RawResponsePromise>, Exception> {
|
||||
val lokiMessage = LokiMessage.from(message) ?: return task { throw Error.MessageConversionFailed }
|
||||
val destination = lokiMessage.recipientPublicKey
|
||||
fun broadcast(event: String) {
|
||||
val dayInMs = 86400000
|
||||
if (message.ttl != dayInMs && message.ttl != 4 * dayInMs) { return }
|
||||
broadcaster.broadcast(event, message.timestamp)
|
||||
}
|
||||
broadcast("calculatingPoW")
|
||||
return lokiMessage.calculatePoW().bind { lokiMessageWithPoW ->
|
||||
broadcast("contactingNetwork")
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
SwarmAPI.shared.getTargetSnodes(destination).map { swarm ->
|
||||
swarm.map { snode ->
|
||||
broadcast("sendingMessage")
|
||||
val parameters = lokiMessageWithPoW.toJSON()
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
invoke(Snode.Method.SendMessage, snode, destination, parameters).map { rawResponse ->
|
||||
val json = rawResponse as? Map<*, *>
|
||||
val powDifficulty = json?.get("difficulty") as? Int
|
||||
if (powDifficulty != null) {
|
||||
if (powDifficulty != SnodeAPI.powDifficulty && powDifficulty < 100) {
|
||||
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||
SnodeAPI.powDifficulty = powDifficulty
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update proof of work difficulty from: ${rawResponse.prettifiedDescription()}.")
|
||||
}
|
||||
rawResponse
|
||||
}
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Parsing
|
||||
|
||||
// The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions.
|
||||
|
||||
public fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List<Envelope> {
|
||||
val messages = rawResponse["messages"] as? List<*>
|
||||
if (messages != null) {
|
||||
updateLastMessageHashValueIfPossible(snode, publicKey, messages)
|
||||
val newRawMessages = removeDuplicates(publicKey, messages)
|
||||
return parseEnvelopes(newRawMessages)
|
||||
} else {
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>) {
|
||||
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
|
||||
val hashValue = lastMessageAsJSON?.get("hash") as? String
|
||||
val expiration = lastMessageAsJSON?.get("expiration") as? Int
|
||||
if (hashValue != null) {
|
||||
database.setLastMessageHashValue(snode, publicKey, hashValue)
|
||||
} else if (rawMessages.isNotEmpty()) {
|
||||
Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeDuplicates(publicKey: String, rawMessages: List<*>): List<*> {
|
||||
val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey)?.toMutableSet() ?: mutableSetOf()
|
||||
return rawMessages.filter { rawMessage ->
|
||||
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||
val hashValue = rawMessageAsJSON?.get("hash") as? String
|
||||
if (hashValue != null) {
|
||||
val isDuplicate = receivedMessageHashValues.contains(hashValue)
|
||||
receivedMessageHashValues.add(hashValue)
|
||||
database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues)
|
||||
!isDuplicate
|
||||
} else {
|
||||
Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEnvelopes(rawMessages: List<*>): List<Envelope> {
|
||||
return rawMessages.mapNotNull { rawMessage ->
|
||||
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||
val base64EncodedData = rawMessageAsJSON?.get("data") as? String
|
||||
val data = base64EncodedData?.let { Base64.decode(it) }
|
||||
if (data != null) {
|
||||
try {
|
||||
MessageWrapper.unwrap(data)
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Error Handling
|
||||
private fun dropSnodeIfNeeded(snode: Snode, publicKey: String? = null) {
|
||||
val oldFailureCount = SwarmAPI.shared.snodeFailureCount[snode] ?: 0
|
||||
val newFailureCount = oldFailureCount + 1
|
||||
SwarmAPI.shared.snodeFailureCount[snode] = newFailureCount
|
||||
Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.")
|
||||
if (newFailureCount >= SwarmAPI.snodeFailureThreshold) {
|
||||
Log.d("Loki", "Failure threshold reached for: $snode; dropping it.")
|
||||
if (publicKey != null) {
|
||||
SwarmAPI.shared.dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||
}
|
||||
SwarmAPI.shared.snodePool = SwarmAPI.shared.snodePool.toMutableSet().minus(snode).toSet()
|
||||
Log.d("Loki", "Snode pool count: ${SwarmAPI.shared.snodePool.count()}.")
|
||||
SwarmAPI.shared.snodeFailureCount[snode] = 0
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception {
|
||||
when (statusCode) {
|
||||
400, 500, 503 -> { // Usually indicates that the snode isn't up to date
|
||||
dropSnodeIfNeeded(snode, publicKey)
|
||||
return Error.HTTPRequestFailed(statusCode)
|
||||
}
|
||||
406 -> {
|
||||
Log.d("Loki", "The user's clock is out of sync with the service node network.")
|
||||
broadcaster.broadcast("clockOutOfSync")
|
||||
return Error.ClockOutOfSync
|
||||
}
|
||||
421 -> {
|
||||
// The snode isn't associated with the given public key anymore
|
||||
if (publicKey != null) {
|
||||
Log.d("Loki", "Invalidating swarm for: $publicKey.")
|
||||
SwarmAPI.shared.dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||
} else {
|
||||
Log.d("Loki", "Got a 421 without an associated public key.")
|
||||
}
|
||||
return Error.SnodeMigrated
|
||||
}
|
||||
432 -> {
|
||||
// The PoW difficulty is too low
|
||||
val powDifficulty = json?.get("difficulty") as? Int
|
||||
if (powDifficulty != null && powDifficulty < 100) {
|
||||
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||
SnodeAPI.powDifficulty = powDifficulty
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update proof of work difficulty.")
|
||||
}
|
||||
return Error.InsufficientProofOfWork
|
||||
}
|
||||
else -> {
|
||||
dropSnodeIfNeeded(snode, publicKey)
|
||||
Log.d("Loki", "Unhandled response code: ${statusCode}.")
|
||||
return Error.Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Convenience
|
||||
typealias RawResponse = Map<*, *>
|
||||
typealias MessageListPromise = Promise<List<Envelope>, Exception>
|
||||
typealias RawResponsePromise = Promise<RawResponse, Exception>
|
||||
// endregion
|
@@ -1,185 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api
|
||||
|
||||
import android.os.Build
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import nl.komponents.kovenant.task
|
||||
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.service.loki.utilities.getRandomElement
|
||||
import org.session.libsignal.service.loki.utilities.prettifiedDescription
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
class SwarmAPI private constructor(private val database: LokiAPIDatabaseProtocol) {
|
||||
internal var snodeFailureCount: MutableMap<Snode, Int> = mutableMapOf()
|
||||
|
||||
internal var snodePool: Set<Snode>
|
||||
get() = database.getSnodePool()
|
||||
set(newValue) { database.setSnodePool(newValue) }
|
||||
|
||||
companion object {
|
||||
|
||||
// use port 4433 if API level can handle network security config and enforce pinned certificates
|
||||
private val seedPort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433
|
||||
private val seedNodePool: Set<String> = setOf(
|
||||
"https://storage.seed1.loki.network:$seedPort",
|
||||
"https://storage.seed3.loki.network:$seedPort",
|
||||
"https://public.loki.foundation:$seedPort"
|
||||
)
|
||||
|
||||
// region Settings
|
||||
private val minimumSnodePoolCount = 64
|
||||
private val minimumSwarmSnodeCount = 2
|
||||
private val targetSwarmSnodeCount = 2
|
||||
private val maxRetryCount = 6
|
||||
|
||||
/**
|
||||
* A snode is kicked out of a swarm and/or the snode pool if it fails this many times.
|
||||
*/
|
||||
internal val snodeFailureThreshold = 2
|
||||
// endregion
|
||||
|
||||
// region Initialization
|
||||
lateinit var shared: SwarmAPI
|
||||
|
||||
fun configureIfNeeded(database: LokiAPIDatabaseProtocol) {
|
||||
if (::shared.isInitialized) { return; }
|
||||
shared = SwarmAPI(database)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Swarm API
|
||||
internal fun getRandomSnode(): Promise<Snode, Exception> {
|
||||
val snodePool = this.snodePool
|
||||
val lastRefreshDate = database.getLastSnodePoolRefreshDate()
|
||||
val now = Date()
|
||||
val needsRefresh = (snodePool.count() < minimumSnodePoolCount) || lastRefreshDate == null || (now.time - lastRefreshDate.time) > 24 * 60 * 60 * 1000
|
||||
if (needsRefresh) {
|
||||
database.setLastSnodePoolRefreshDate(now)
|
||||
|
||||
val target = seedNodePool.random()
|
||||
val url = "$target/json_rpc"
|
||||
Log.d("Loki", "Populating snode pool using: $target.")
|
||||
val parameters = mapOf(
|
||||
"method" to "get_n_service_nodes",
|
||||
"params" to mapOf(
|
||||
"active_only" to true,
|
||||
"fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true )
|
||||
)
|
||||
)
|
||||
val deferred = deferred<Snode, Exception>()
|
||||
deferred<Snode, Exception>(SnodeAPI.sharedContext)
|
||||
ThreadUtils.queue {
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true)
|
||||
val intermediate = json["result"] as? Map<*, *>
|
||||
val rawSnodes = intermediate?.get("service_node_states") as? List<*>
|
||||
if (rawSnodes != null) {
|
||||
@Suppress("NAME_SHADOWING") val snodePool = rawSnodes.mapNotNull { rawSnode ->
|
||||
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||
val address = rawSnodeAsJSON?.get("public_ip") as? String
|
||||
val port = rawSnodeAsJSON?.get("storage_port") as? Int
|
||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}.toMutableSet()
|
||||
Log.d("Loki", "Persisting snode pool to database.")
|
||||
this.snodePool = snodePool
|
||||
try {
|
||||
deferred.resolve(snodePool.getRandomElement())
|
||||
} catch (exception: Exception) {
|
||||
Log.d("Loki", "Got an empty snode pool from: $target.")
|
||||
deferred.reject(SnodeAPI.Error.Generic)
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.")
|
||||
deferred.reject(SnodeAPI.Error.Generic)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
} else {
|
||||
return Promise.of(snodePool.getRandomElement())
|
||||
}
|
||||
}
|
||||
|
||||
public fun getSwarm(publicKey: String): Promise<Set<Snode>, Exception> {
|
||||
val cachedSwarm = database.getSwarm(publicKey)
|
||||
if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
|
||||
val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue
|
||||
cachedSwarmCopy.addAll(cachedSwarm)
|
||||
return task { cachedSwarmCopy }
|
||||
} else {
|
||||
val parameters = mapOf( "pubKey" to publicKey )
|
||||
return getRandomSnode().bind {
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
SnodeAPI.shared.invoke(Snode.Method.GetSwarm, it, publicKey, parameters)
|
||||
}
|
||||
|
||||
}.map(SnodeAPI.sharedContext) {
|
||||
parseSnodes(it).toSet()
|
||||
}.success {
|
||||
database.setSwarm(publicKey, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
|
||||
val swarm = database.getSwarm(publicKey)?.toMutableSet()
|
||||
if (swarm != null && swarm.contains(snode)) {
|
||||
swarm.remove(snode)
|
||||
database.setSwarm(publicKey, swarm)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
|
||||
// SecureRandom() should be cryptographically secure
|
||||
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() }
|
||||
}
|
||||
|
||||
internal fun getTargetSnodes(publicKey: String): Promise<List<Snode>, Exception> {
|
||||
// SecureRandom() should be cryptographically secure
|
||||
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Parsing
|
||||
private fun parseSnodes(rawResponse: Any): List<Snode> {
|
||||
val json = rawResponse as? Map<*, *>
|
||||
val rawSnodes = json?.get("snodes") as? List<*>
|
||||
if (rawSnodes != null) {
|
||||
return rawSnodes.mapNotNull { rawSnode ->
|
||||
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||
val address = rawSnodeAsJSON?.get("ip") as? String
|
||||
val portAsString = rawSnodeAsJSON?.get("port") as? String
|
||||
val port = portAsString?.toInt()
|
||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.")
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@@ -1,64 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api.crypto
|
||||
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.service.loki.api.SnodeAPI
|
||||
import java.math.BigInteger
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Based on the desktop messenger's proof of work implementation. For more information, see libloki/proof-of-work.js.
|
||||
*/
|
||||
object ProofOfWork {
|
||||
|
||||
// region Settings
|
||||
private val nonceSize = 8
|
||||
// endregion
|
||||
|
||||
// region Implementation
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
fun calculate(data: String, hexEncodedPublicKey: String, timestamp: Long, ttl: Int): String? {
|
||||
try {
|
||||
val sha512 = MessageDigest.getInstance("SHA-512")
|
||||
val payloadAsString = timestamp.toString() + ttl.toString() + hexEncodedPublicKey + data
|
||||
val payload = payloadAsString.toByteArray()
|
||||
val target = determineTarget(ttl, payload.size)
|
||||
var currentTrialValue = ULong.MAX_VALUE
|
||||
var nonce: Long = 0
|
||||
val initialHash = sha512.digest(payload)
|
||||
while (currentTrialValue > target) {
|
||||
nonce += 1
|
||||
// This is different from bitmessage's PoW implementation
|
||||
// newHash = hash(nonce + hash(data)) → hash(nonce + initialHash)
|
||||
val newHash = sha512.digest(nonce.toByteArray() + initialHash)
|
||||
currentTrialValue = newHash.sliceArray(0 until nonceSize).toULong()
|
||||
}
|
||||
return Base64.encodeBytes(nonce.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Couldn't calculate proof of work due to error: $e.")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
private fun determineTarget(ttl: Int, payloadSize: Int): ULong {
|
||||
val x1 = BigInteger.valueOf(2).pow(16) - 1.toBigInteger()
|
||||
val x2 = BigInteger.valueOf(2).pow(64) - 1.toBigInteger()
|
||||
val size = (payloadSize + nonceSize).toBigInteger()
|
||||
val ttlInSeconds = (ttl / 1000).toBigInteger()
|
||||
val x3 = (ttlInSeconds * size) / x1
|
||||
val x4 = size + x3
|
||||
val x5 = SnodeAPI.powDifficulty.toBigInteger() * x4
|
||||
return (x2 / x5).toULong()
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Convenience
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
private fun BigInteger.toULong() = toLong().toULong()
|
||||
private fun Long.toByteArray() = ByteBuffer.allocate(8).putLong(this).array()
|
||||
@kotlin.ExperimentalUnsignedTypes
|
||||
private fun ByteArray.toULong() = ByteBuffer.wrap(this).long.toULong()
|
||||
// endregion
|
@@ -1,77 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api.fileserver
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.Request
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.LokiDotNetAPI
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, database) {
|
||||
|
||||
companion object {
|
||||
// region Settings
|
||||
internal val fileServerPublicKey = "62509D59BDEEC404DD0D489C1E15BA8F94FD3D619B01C1BF48A9922BFCB7311C"
|
||||
internal val maxRetryCount = 4
|
||||
|
||||
public val maxFileSize = 10_000_000 // 10 MB
|
||||
/**
|
||||
* The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes
|
||||
* is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP
|
||||
* request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also
|
||||
* be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when
|
||||
* uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only
|
||||
* possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds.
|
||||
*/
|
||||
public val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5?
|
||||
public val fileStorageBucketURL = "https://file-static.lokinet.org"
|
||||
// endregion
|
||||
|
||||
// region Initialization
|
||||
lateinit var shared: FileServerAPI
|
||||
|
||||
/**
|
||||
* Must be called before `LokiAPI` is used.
|
||||
*/
|
||||
fun configure(userPublicKey: String, userPrivateKey: ByteArray, database: LokiAPIDatabaseProtocol) {
|
||||
if (Companion::shared.isInitialized) { return }
|
||||
val server = "https://file.getsession.org"
|
||||
shared = FileServerAPI(server, userPublicKey, userPrivateKey, database)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Open Group Server Public Key
|
||||
fun getPublicKeyForOpenGroupServer(openGroupServer: String): Promise<String, Exception> {
|
||||
val publicKey = database.getOpenGroupPublicKey(openGroupServer)
|
||||
if (publicKey != null && PublicKeyValidation.isValid(publicKey, 64, false)) {
|
||||
return Promise.of(publicKey)
|
||||
} else {
|
||||
val url = "$server/loki/v1/getOpenGroupKey/${URL(openGroupServer).host}"
|
||||
val request = Request.Builder().url(url)
|
||||
request.addHeader("Content-Type", "application/json")
|
||||
request.addHeader("Authorization", "Bearer loki") // Tokenless request; use a dummy token
|
||||
return OnionRequestAPI.sendOnionRequest(request.build(), server, fileServerPublicKey).map { json ->
|
||||
try {
|
||||
val bodyAsString = json["data"] as String
|
||||
val body = JsonUtil.fromJson(bodyAsString)
|
||||
val base64EncodedPublicKey = body.get("data").asText()
|
||||
val prefixedPublicKey = Base64.decode(base64EncodedPublicKey)
|
||||
val hexEncodedPrefixedPublicKey = prefixedPublicKey.toHexString()
|
||||
val result = hexEncodedPrefixedPublicKey.removing05PrefixIfNeeded()
|
||||
database.setOpenGroupPublicKey(openGroupServer, result)
|
||||
result
|
||||
} catch (exception: Exception) {
|
||||
Log.d("Loki", "Couldn't parse open group public key from: $json.")
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,460 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api.onionrequests
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.all
|
||||
import nl.komponents.kovenant.deferred
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.Request
|
||||
import org.session.libsignal.utilities.logging.Log
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.*
|
||||
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
|
||||
import org.session.libsignal.service.loki.api.utilities.*
|
||||
import org.session.libsignal.service.loki.api.utilities.EncryptionResult
|
||||
import org.session.libsignal.service.loki.api.utilities.getBodyForOnionRequest
|
||||
import org.session.libsignal.service.loki.api.utilities.getHeadersForOnionRequest
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
import org.session.libsignal.utilities.*
|
||||
|
||||
private typealias Path = List<Snode>
|
||||
|
||||
/**
|
||||
* See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||
*/
|
||||
public object OnionRequestAPI {
|
||||
private val pathFailureCount = mutableMapOf<Path, Int>()
|
||||
private val snodeFailureCount = mutableMapOf<Snode, Int>()
|
||||
public var guardSnodes = setOf<Snode>()
|
||||
public var paths: List<Path> // Not a set to ensure we consistently show the same path to the user
|
||||
get() = SnodeAPI.shared.database.getOnionRequestPaths()
|
||||
set(newValue) {
|
||||
if (newValue.isEmpty()) {
|
||||
SnodeAPI.shared.database.clearOnionRequestPaths()
|
||||
} else {
|
||||
SnodeAPI.shared.database.setOnionRequestPaths(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// region Settings
|
||||
/**
|
||||
* The number of snodes (including the guard snode) in a path.
|
||||
*/
|
||||
private val pathSize = 3
|
||||
/**
|
||||
* The number of times a path can fail before it's replaced.
|
||||
*/
|
||||
private val pathFailureThreshold = 1
|
||||
/**
|
||||
* The number of times a snode can fail before it's replaced.
|
||||
*/
|
||||
private val snodeFailureThreshold = 1
|
||||
/**
|
||||
* The number of paths to maintain.
|
||||
*/
|
||||
public val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
|
||||
|
||||
/**
|
||||
* The number of guard snodes required to maintain `targetPathCount` paths.
|
||||
*/
|
||||
private val targetGuardSnodeCount
|
||||
get() = targetPathCount // One per path
|
||||
// endregion
|
||||
|
||||
class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>)
|
||||
: Exception("HTTP request failed at destination with status code $statusCode.")
|
||||
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
|
||||
|
||||
private data class OnionBuildingResult(
|
||||
internal val guardSnode: Snode,
|
||||
internal val finalEncryptionResult: EncryptionResult,
|
||||
internal val destinationSymmetricKey: ByteArray
|
||||
)
|
||||
|
||||
internal sealed class Destination {
|
||||
class Snode(val snode: org.session.libsignal.service.loki.api.Snode) : Destination()
|
||||
class Server(val host: String, val target: String, val x25519PublicKey: String) : Destination()
|
||||
}
|
||||
|
||||
// region Private API
|
||||
/**
|
||||
* Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
|
||||
*/
|
||||
private fun testSnode(snode: Snode): Promise<Unit, Exception> {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
ThreadUtils.queue { // No need to block the shared context for this
|
||||
val url = "${snode.address}:${snode.port}/get_stats/v1"
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.GET, url)
|
||||
val version = json["version"] as? String
|
||||
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@queue }
|
||||
if (version >= "2.0.7") {
|
||||
deferred.resolve(Unit)
|
||||
} else {
|
||||
val message = "Unsupported snode version: $version."
|
||||
Log.d("Loki", message)
|
||||
deferred.reject(Exception(message))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not
|
||||
* enough (reliable) snodes are available.
|
||||
*/
|
||||
private fun getGuardSnodes(reusableGuardSnodes: List<Snode>): Promise<Set<Snode>, Exception> {
|
||||
if (guardSnodes.count() >= targetGuardSnodeCount) {
|
||||
return Promise.of(guardSnodes)
|
||||
} else {
|
||||
Log.d("Loki", "Populating guard snode cache.")
|
||||
return SwarmAPI.shared.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||
var unusedSnodes = SwarmAPI.shared.snodePool.minus(reusableGuardSnodes)
|
||||
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||
if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() }
|
||||
fun getGuardSnode(): Promise<Snode, Exception> {
|
||||
val candidate = unusedSnodes.getRandomElementOrNull()
|
||||
?: return Promise.ofFail(InsufficientSnodesException())
|
||||
unusedSnodes = unusedSnodes.minus(candidate)
|
||||
Log.d("Loki", "Testing guard snode: $candidate.")
|
||||
// Loop until a reliable guard snode is found
|
||||
val deferred = deferred<Snode, Exception>()
|
||||
testSnode(candidate).success {
|
||||
deferred.resolve(candidate)
|
||||
}.fail {
|
||||
getGuardSnode().success {
|
||||
deferred.resolve(candidate)
|
||||
}.fail { exception ->
|
||||
if (exception is InsufficientSnodesException) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() }
|
||||
all(promises).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||
val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet()
|
||||
OnionRequestAPI.guardSnodes = guardSnodesAsSet
|
||||
guardSnodesAsSet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns `targetPathCount` paths. The returned promise errors out if not
|
||||
* enough (reliable) snodes are available.
|
||||
*/
|
||||
private fun buildPaths(reusablePaths: List<Path>): Promise<List<Path>, Exception> {
|
||||
Log.d("Loki", "Building onion request paths.")
|
||||
SnodeAPI.shared.broadcaster.broadcast("buildingPaths")
|
||||
return SwarmAPI.shared.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||
val reusableGuardSnodes = reusablePaths.map { it[0] }
|
||||
getGuardSnodes(reusableGuardSnodes).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||
var unusedSnodes = SwarmAPI.shared.snodePool.minus(guardSnodes).minus(reusablePaths.flatten())
|
||||
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||
val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
|
||||
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).map {
|
||||
val pathSnode = unusedSnodes.getRandomElement()
|
||||
unusedSnodes = unusedSnodes.minus(pathSnode)
|
||||
pathSnode
|
||||
}
|
||||
Log.d("Loki", "Built new onion request path: $result.")
|
||||
result
|
||||
}
|
||||
}.map { paths ->
|
||||
OnionRequestAPI.paths = paths + reusablePaths
|
||||
SnodeAPI.shared.broadcaster.broadcast("pathsBuilt")
|
||||
paths
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
*/
|
||||
private fun getPath(snodeToExclude: Snode?): Promise<Path, Exception> {
|
||||
if (pathSize < 1) { throw Exception("Can't build path of size zero.") }
|
||||
val paths = this.paths
|
||||
val guardSnodes = mutableSetOf<Snode>()
|
||||
if (paths.isNotEmpty()) {
|
||||
guardSnodes.add(paths[0][0])
|
||||
if (paths.count() >= 2) {
|
||||
guardSnodes.add(paths[1][0])
|
||||
}
|
||||
}
|
||||
OnionRequestAPI.guardSnodes = guardSnodes
|
||||
fun getPath(paths: List<Path>): Path {
|
||||
if (snodeToExclude != null) {
|
||||
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
|
||||
} else {
|
||||
return paths.getRandomElement()
|
||||
}
|
||||
}
|
||||
if (paths.count() >= targetPathCount) {
|
||||
return Promise.of(getPath(paths))
|
||||
} else if (paths.isNotEmpty()) {
|
||||
if (paths.any { !it.contains(snodeToExclude) }) {
|
||||
buildPaths(paths) // Re-build paths in the background
|
||||
return Promise.of(getPath(paths))
|
||||
} else {
|
||||
return buildPaths(paths).map(SnodeAPI.sharedContext) { newPaths ->
|
||||
getPath(newPaths)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return buildPaths(listOf()).map(SnodeAPI.sharedContext) { newPaths ->
|
||||
getPath(newPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropGuardSnode(snode: Snode) {
|
||||
guardSnodes = guardSnodes.filter { it != snode }.toSet()
|
||||
}
|
||||
|
||||
private fun dropSnode(snode: Snode) {
|
||||
// We repair the path here because we can do it sync. In the case where we drop a whole
|
||||
// path we leave the re-building up to getPath() because re-building the path in that case
|
||||
// is async.
|
||||
snodeFailureCount[snode] = 0
|
||||
val oldPaths = paths.toMutableList()
|
||||
val pathIndex = oldPaths.indexOfFirst { it.contains(snode) }
|
||||
if (pathIndex == -1) { return }
|
||||
val path = oldPaths[pathIndex].toMutableList()
|
||||
val snodeIndex = path.indexOf(snode)
|
||||
if (snodeIndex == -1) { return }
|
||||
path.removeAt(snodeIndex)
|
||||
val unusedSnodes = SwarmAPI.shared.snodePool.minus(oldPaths.flatten())
|
||||
if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() }
|
||||
path.add(unusedSnodes.getRandomElement())
|
||||
// Don't test the new snode as this would reveal the user's IP
|
||||
oldPaths.removeAt(pathIndex)
|
||||
val newPaths = oldPaths + listOf( path )
|
||||
paths = newPaths
|
||||
}
|
||||
|
||||
private fun dropPath(path: Path) {
|
||||
pathFailureCount[path] = 0
|
||||
val paths = OnionRequestAPI.paths.toMutableList()
|
||||
val pathIndex = paths.indexOf(path)
|
||||
if (pathIndex == -1) { return }
|
||||
paths.removeAt(pathIndex)
|
||||
OnionRequestAPI.paths = paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an onion around `payload` and returns the result.
|
||||
*/
|
||||
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise<OnionBuildingResult, Exception> {
|
||||
lateinit var guardSnode: Snode
|
||||
lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination
|
||||
lateinit var encryptionResult: EncryptionResult
|
||||
val snodeToExclude = when (destination) {
|
||||
is Destination.Snode -> destination.snode
|
||||
is Destination.Server -> null
|
||||
}
|
||||
return getPath(snodeToExclude).bind(SnodeAPI.sharedContext) { path ->
|
||||
guardSnode = path.first()
|
||||
// Encrypt in reverse order, i.e. the destination first
|
||||
OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind(SnodeAPI.sharedContext) { r ->
|
||||
destinationSymmetricKey = r.symmetricKey
|
||||
// Recursively encrypt the layers of the onion (again in reverse order)
|
||||
encryptionResult = r
|
||||
@Suppress("NAME_SHADOWING") var path = path
|
||||
var rhs = destination
|
||||
fun addLayer(): Promise<EncryptionResult, Exception> {
|
||||
if (path.isEmpty()) {
|
||||
return Promise.of(encryptionResult)
|
||||
} else {
|
||||
val lhs = Destination.Snode(path.last())
|
||||
path = path.dropLast(1)
|
||||
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind(SnodeAPI.sharedContext) { r ->
|
||||
encryptionResult = r
|
||||
rhs = lhs
|
||||
addLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
addLayer()
|
||||
}
|
||||
}.map(SnodeAPI.sharedContext) { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an onion request to `destination`. Builds new paths as needed.
|
||||
*/
|
||||
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>, isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||
val deferred = deferred<Map<*, *>, Exception>()
|
||||
var guardSnode: Snode? = null
|
||||
buildOnionForDestination(payload, destination).success { result ->
|
||||
guardSnode = result.guardSnode
|
||||
val url = "${guardSnode!!.address}:${guardSnode!!.port}/onion_req/v2"
|
||||
val finalEncryptionResult = result.finalEncryptionResult
|
||||
val onion = finalEncryptionResult.ciphertext
|
||||
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPI.maxFileSize.toDouble()) {
|
||||
Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
|
||||
}
|
||||
@Suppress("NAME_SHADOWING") val parameters = mapOf(
|
||||
"ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
)
|
||||
val body: ByteArray
|
||||
try {
|
||||
body = OnionRequestEncryption.encode(onion, parameters)
|
||||
} catch (exception: Exception) {
|
||||
return@success deferred.reject(exception)
|
||||
}
|
||||
val destinationSymmetricKey = result.destinationSymmetricKey
|
||||
ThreadUtils.queue {
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, body)
|
||||
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@queue deferred.reject(Exception("Invalid JSON"))
|
||||
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
|
||||
try {
|
||||
val plaintext = DecryptionUtilities.decryptUsingAESGCM(ivAndCiphertext, destinationSymmetricKey)
|
||||
try {
|
||||
@Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java)
|
||||
val statusCode = json["status"] as Int
|
||||
if (statusCode == 406) {
|
||||
@Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." )
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||
return@queue deferred.reject(exception)
|
||||
} else if (json["body"] != null) {
|
||||
@Suppress("NAME_SHADOWING") val body: Map<*, *>
|
||||
if (json["body"] is Map<*, *>) {
|
||||
body = json["body"] as Map<*, *>
|
||||
} else {
|
||||
val bodyAsString = json["body"] as String
|
||||
if (!isJSONRequired) {
|
||||
body = mapOf( "result" to bodyAsString )
|
||||
} else {
|
||||
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
|
||||
}
|
||||
}
|
||||
if (statusCode != 200) {
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||
return@queue deferred.reject(exception)
|
||||
}
|
||||
deferred.resolve(body)
|
||||
} else {
|
||||
if (statusCode != 200) {
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, json)
|
||||
return@queue deferred.reject(exception)
|
||||
}
|
||||
deferred.resolve(json)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
}.fail { exception ->
|
||||
deferred.reject(exception)
|
||||
}
|
||||
val promise = deferred.promise
|
||||
promise.fail { exception ->
|
||||
val path = if (guardSnode != null) paths.firstOrNull { it.contains(guardSnode!!) } else null
|
||||
if (exception is HTTP.HTTPRequestFailedException) {
|
||||
fun handleUnspecificError() {
|
||||
if (path == null) { return }
|
||||
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0
|
||||
pathFailureCount += 1
|
||||
if (pathFailureCount >= pathFailureThreshold) {
|
||||
dropGuardSnode(guardSnode!!)
|
||||
path.forEach { snode ->
|
||||
@Suppress("ThrowableNotThrown")
|
||||
SnodeAPI.shared.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw
|
||||
}
|
||||
dropPath(path)
|
||||
} else {
|
||||
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
|
||||
}
|
||||
}
|
||||
val json = exception.json
|
||||
val message = json?.get("result") as? String
|
||||
val prefix = "Next node not found: "
|
||||
if (message != null && message.startsWith(prefix)) {
|
||||
val ed25519PublicKey = message.substringAfter(prefix)
|
||||
val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey }
|
||||
if (snode != null) {
|
||||
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0
|
||||
snodeFailureCount += 1
|
||||
if (snodeFailureCount >= snodeFailureThreshold) {
|
||||
@Suppress("ThrowableNotThrown")
|
||||
SnodeAPI.shared.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw
|
||||
try {
|
||||
dropSnode(snode)
|
||||
} catch (exception: Exception) {
|
||||
handleUnspecificError()
|
||||
}
|
||||
} else {
|
||||
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
|
||||
}
|
||||
} else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
} else if (message == "Loki Server error") {
|
||||
// Do nothing
|
||||
} else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Internal API
|
||||
/**
|
||||
* Sends an onion request to `snode`. Builds new paths as needed.
|
||||
*/
|
||||
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String): Promise<Map<*, *>, Exception> {
|
||||
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
|
||||
@Suppress("NAME_SHADOWING") val exception = exception as? HTTPRequestFailedAtDestinationException ?: throw exception
|
||||
throw SnodeAPI.shared.handleSnodeError(exception.statusCode, exception.json, snode, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an onion request to `server`. Builds new paths as needed.
|
||||
*
|
||||
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
|
||||
*/
|
||||
public fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc", isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||
val headers = request.getHeadersForOnionRequest()
|
||||
val url = request.url()
|
||||
val urlAsString = url.toString()
|
||||
val host = url.host()
|
||||
val endpoint = when {
|
||||
server.count() < urlAsString.count() -> urlAsString.substringAfter("$server/")
|
||||
else -> ""
|
||||
}
|
||||
val body = request.getBodyForOnionRequest() ?: "null"
|
||||
val payload = mapOf(
|
||||
"body" to body,
|
||||
"endpoint" to endpoint,
|
||||
"method" to request.method(),
|
||||
"headers" to headers
|
||||
)
|
||||
val destination = Destination.Server(host, target, x25519PublicKey)
|
||||
return sendOnionRequest(destination, payload, isJSONRequired).recover { exception ->
|
||||
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.")
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
package org.session.libsignal.service.loki.api.onionrequests
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.utilities.EncryptionResult
|
||||
import org.session.libsignal.service.loki.api.utilities.EncryptionUtilities
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.session.libsignal.service.loki.utilities.toHexString
|
||||
import java.nio.Buffer
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
object OnionRequestEncryption {
|
||||
|
||||
internal fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray {
|
||||
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
|
||||
val jsonAsData = JsonUtil.toJson(json).toByteArray()
|
||||
val ciphertextSize = ciphertext.size
|
||||
val buffer = ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
buffer.putInt(ciphertextSize)
|
||||
val ciphertextSizeAsData = ByteArray(buffer.capacity())
|
||||
// Casting here avoids an issue where this gets compiled down to incorrect byte code. See
|
||||
// https://github.com/eclipse/jetty.project/issues/3244 for more info
|
||||
(buffer as Buffer).position(0)
|
||||
buffer.get(ciphertextSizeAsData)
|
||||
return ciphertextSizeAsData + ciphertext + jsonAsData
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||
*/
|
||||
internal fun encryptPayloadForDestination(payload: Map<*, *>, destination: OnionRequestAPI.Destination): Promise<EncryptionResult, Exception> {
|
||||
val deferred = deferred<EncryptionResult, Exception>()
|
||||
ThreadUtils.queue {
|
||||
try {
|
||||
// Wrapping isn't needed for file server or open group onion requests
|
||||
when (destination) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key
|
||||
val payloadAsData = JsonUtil.toJson(payload).toByteArray()
|
||||
val plaintext = encode(payloadAsData, mapOf( "headers" to "" ))
|
||||
val result = EncryptionUtilities.encryptForX25519PublicKey(plaintext, snodeX25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
val plaintext = JsonUtil.toJson(payload).toByteArray()
|
||||
val result = EncryptionUtilities.encryptForX25519PublicKey(plaintext, destination.x25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
*/
|
||||
internal fun encryptHop(lhs: OnionRequestAPI.Destination, rhs: OnionRequestAPI.Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
|
||||
val deferred = deferred<EncryptionResult, Exception>()
|
||||
ThreadUtils.queue {
|
||||
try {
|
||||
val payload: MutableMap<String, Any>
|
||||
when (rhs) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
payload = mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST" )
|
||||
}
|
||||
}
|
||||
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
val x25519PublicKey: String
|
||||
when (lhs) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
x25519PublicKey = lhs.x25519PublicKey
|
||||
}
|
||||
}
|
||||
val plaintext = encode(previousEncryptionResult.ciphertext, payload)
|
||||
val result = EncryptionUtilities.encryptForX25519PublicKey(plaintext, x25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
}
|
@@ -1,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 )
|
||||
}
|
||||
}
|
@@ -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
|
||||
)
|
@@ -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
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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) {
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user