mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-24 02:25:19 +00:00
Merge branch 'refactor' of https://github.com/RyanRory/loki-messenger-android into refactor
This commit is contained in:
commit
841e4aa493
@ -33,12 +33,36 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Local:
|
||||||
|
implementation project(":libsignal")
|
||||||
|
// Remote:
|
||||||
|
implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
implementation 'androidx.core:core-ktx:1.3.2'
|
implementation 'androidx.core:core-ktx:1.3.2'
|
||||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||||
implementation 'com.google.android.material:material:1.2.1'
|
implementation 'com.google.android.material:material:1.2.1'
|
||||||
|
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||||
testImplementation 'junit:junit:4.+'
|
testImplementation 'junit:junit:4.+'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
|
||||||
|
|
||||||
|
// from libsignal:
|
||||||
|
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||||
|
implementation "com.googlecode.libphonenumber:libphonenumber:8.10.7"
|
||||||
|
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
|
||||||
|
|
||||||
|
implementation "org.whispersystems:curve25519-java:$curve25519Version"
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||||
|
implementation "org.threeten:threetenbp:1.3.6"
|
||||||
|
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
|
||||||
|
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
|
||||||
|
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||||
|
|
||||||
|
testImplementation "junit:junit:3.8.2"
|
||||||
|
testImplementation "org.assertj:assertj-core:1.7.1"
|
||||||
|
testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0"
|
||||||
|
|
||||||
}
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package org.session.libsession.messaging
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.loki.SessionResetProtocol
|
||||||
|
import org.session.libsignal.libsignal.state.*
|
||||||
|
import org.session.libsignal.metadata.certificate.CertificateValidator
|
||||||
|
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol
|
||||||
|
|
||||||
|
class Configuration(val storage: StorageProtocol, val signalStorage: SignalProtocolStore, val sskDatabase: SharedSenderKeysDatabaseProtocol, val sessionResetImp: SessionResetProtocol, val certificateValidator: CertificateValidator) {
|
||||||
|
companion object {
|
||||||
|
lateinit var shared: Configuration
|
||||||
|
|
||||||
|
fun configure(storage: StorageProtocol,
|
||||||
|
signalStorage: SignalProtocolStore,
|
||||||
|
sskDatabase: SharedSenderKeysDatabaseProtocol,
|
||||||
|
sessionResetImp: SessionResetProtocol,
|
||||||
|
certificateValidator: CertificateValidator
|
||||||
|
) {
|
||||||
|
if (Companion::shared.isInitialized) { return }
|
||||||
|
shared = Configuration(storage, signalStorage, sskDatabase, sessionResetImp, certificateValidator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
package org.session.libsession.messaging
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||||
|
import org.session.libsession.messaging.jobs.Job
|
||||||
|
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||||
|
import org.session.libsession.messaging.opengroups.OpenGroup
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.ecc.ECKeyPair
|
||||||
|
import org.session.libsignal.libsignal.ecc.ECPrivateKey
|
||||||
|
|
||||||
|
interface StorageProtocol {
|
||||||
|
|
||||||
|
// General
|
||||||
|
fun getUserPublicKey(): String?
|
||||||
|
fun getUserKeyPair(): ECKeyPair?
|
||||||
|
fun getUserDisplayName(): String?
|
||||||
|
fun getUserProfileKey(): ByteArray?
|
||||||
|
fun getUserProfilePictureURL(): String?
|
||||||
|
|
||||||
|
// Signal Protocol
|
||||||
|
|
||||||
|
fun getOrGenerateRegistrationID(): Int //TODO needs impl
|
||||||
|
|
||||||
|
// Shared Sender Keys
|
||||||
|
fun getClosedGroupPrivateKey(publicKey: String): ECPrivateKey?
|
||||||
|
fun isClosedGroup(publicKey: String): Boolean
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
fun persist(job: Job)
|
||||||
|
fun markJobAsSucceeded(job: Job)
|
||||||
|
fun markJobAsFailed(job: Job)
|
||||||
|
fun getAllPendingJobs(type: String): List<Job>
|
||||||
|
fun getAttachmentUploadJob(attachmentID: String): AttachmentUploadJob?
|
||||||
|
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
|
||||||
|
fun resumeMessageSendJobIfNeeded(messageSendJobID: String)
|
||||||
|
fun isJobCanceled(job: Job): Boolean
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
fun getAuthToken(server: String): String?
|
||||||
|
fun setAuthToken(server: String, newValue: String?)
|
||||||
|
fun removeAuthToken(server: String)
|
||||||
|
|
||||||
|
// Open Groups
|
||||||
|
fun getOpenGroup(threadID: String): OpenGroup?
|
||||||
|
fun getThreadID(openGroupID: String): String?
|
||||||
|
|
||||||
|
// Open Group Public Keys
|
||||||
|
fun getOpenGroupPublicKey(server: String): String?
|
||||||
|
fun setOpenGroupPublicKey(server: String, newValue: String)
|
||||||
|
|
||||||
|
// Last Message Server ID
|
||||||
|
fun getLastMessageServerID(group: Long, server: String): Long?
|
||||||
|
fun setLastMessageServerID(group: Long, server: String, newValue: Long)
|
||||||
|
fun removeLastMessageServerID(group: Long, server: String)
|
||||||
|
|
||||||
|
// Last Deletion Server ID
|
||||||
|
fun getLastDeletionServerID(group: Long, server: String): Long?
|
||||||
|
fun setLastDeletionServerID(group: Long, server: String, newValue: Long)
|
||||||
|
fun removeLastDeletionServerID(group: Long, server: String)
|
||||||
|
|
||||||
|
// Open Group Metadata
|
||||||
|
fun setUserCount(group: Long, server: String, newValue: Int)
|
||||||
|
fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String)
|
||||||
|
fun getOpenGroupProfilePictureURL(group: Long, server: String): String?
|
||||||
|
fun updateTitle(groupID: String, newValue: String)
|
||||||
|
fun updateProfilePicture(groupID: String, newValue: ByteArray)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fun getSessionRequestSentTimestamp(publicKey: String): Long?
|
||||||
|
fun setSessionRequestSentTimestamp(publicKey: String, newValue: Long)
|
||||||
|
fun getSessionRequestProcessedTimestamp(publicKey: String): Long?
|
||||||
|
fun setSessionRequestProcessedTimestamp(publicKey: String, newValue: Long)
|
||||||
|
}
|
@ -0,0 +1,262 @@
|
|||||||
|
package org.session.libsession.messaging.fileserver
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.libsignal.util.Hex
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.api.SnodeAPI
|
||||||
|
import org.session.libsignal.service.loki.api.LokiDotNetAPI
|
||||||
|
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||||
|
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||||
|
import org.session.libsignal.service.loki.protocol.shelved.multidevice.DeviceLink
|
||||||
|
import org.session.libsignal.service.loki.utilities.*
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.collections.set
|
||||||
|
|
||||||
|
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, database) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// region Settings
|
||||||
|
/**
|
||||||
|
* Deprecated.
|
||||||
|
*/
|
||||||
|
private val deviceLinkType = "network.loki.messenger.devicemapping"
|
||||||
|
/**
|
||||||
|
* Deprecated.
|
||||||
|
*/
|
||||||
|
private val deviceLinkRequestCache = ConcurrentHashMap<String, Promise<Set<DeviceLink>, Exception>>()
|
||||||
|
/**
|
||||||
|
* Deprecated.
|
||||||
|
*/
|
||||||
|
private val deviceLinkUpdateInterval = 60 * 1000
|
||||||
|
private val lastDeviceLinkUpdate = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
internal val fileServerPublicKey = "62509D59BDEEC404DD0D489C1E15BA8F94FD3D619B01C1BF48A9922BFCB7311C"
|
||||||
|
internal val maxRetryCount = 4
|
||||||
|
|
||||||
|
public val maxFileSize = 10_000_000 // 10 MB
|
||||||
|
/**
|
||||||
|
* The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes
|
||||||
|
* is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP
|
||||||
|
* request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also
|
||||||
|
* be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when
|
||||||
|
* uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only
|
||||||
|
* possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds.
|
||||||
|
*/
|
||||||
|
public val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5?
|
||||||
|
public val fileStorageBucketURL = "https://file-static.lokinet.org"
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Initialization
|
||||||
|
lateinit var shared: FileServerAPI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called before `LokiAPI` is used.
|
||||||
|
*/
|
||||||
|
fun configure(userPublicKey: String, userPrivateKey: ByteArray, database: LokiAPIDatabaseProtocol) {
|
||||||
|
if (Companion::shared.isInitialized) { return }
|
||||||
|
val server = "https://file.getsession.org"
|
||||||
|
shared = FileServerAPI(server, userPublicKey, userPrivateKey, database)
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Device Link Update Result
|
||||||
|
sealed class DeviceLinkUpdateResult {
|
||||||
|
class Success(val publicKey: String, val deviceLinks: Set<DeviceLink>) : DeviceLinkUpdateResult()
|
||||||
|
class Failure(val publicKey: String, val error: Exception) : DeviceLinkUpdateResult()
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region API
|
||||||
|
public fun hasDeviceLinkCacheExpired(referenceTime: Long = System.currentTimeMillis(), publicKey: String): Boolean {
|
||||||
|
return !lastDeviceLinkUpdate.containsKey(publicKey) || (referenceTime - lastDeviceLinkUpdate[publicKey]!! > deviceLinkUpdateInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeviceLinks(publicKey: String, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
|
||||||
|
return Promise.of(setOf())
|
||||||
|
/*
|
||||||
|
if (deviceLinkRequestCache.containsKey(publicKey) && !isForcedUpdate) {
|
||||||
|
val result = deviceLinkRequestCache[publicKey]
|
||||||
|
if (result != null) { return result } // A request was already pending
|
||||||
|
}
|
||||||
|
val promise = getDeviceLinks(setOf(publicKey), isForcedUpdate)
|
||||||
|
deviceLinkRequestCache[publicKey] = promise
|
||||||
|
promise.always {
|
||||||
|
deviceLinkRequestCache.remove(publicKey)
|
||||||
|
}
|
||||||
|
return promise
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeviceLinks(publicKeys: Set<String>, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
|
||||||
|
return Promise.of(setOf())
|
||||||
|
/*
|
||||||
|
val validPublicKeys = publicKeys.filter { PublicKeyValidation.isValid(it) }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// IMPORTANT: Don't fetch device links for the current user (i.e. don't remove the it != userHexEncodedPublicKey) check below
|
||||||
|
val updatees = validPublicKeys.filter { it != userPublicKey && (hasDeviceLinkCacheExpired(now, it) || isForcedUpdate) }.toSet()
|
||||||
|
val cachedDeviceLinks = validPublicKeys.minus(updatees).flatMap { database.getDeviceLinks(it) }.toSet()
|
||||||
|
if (updatees.isEmpty()) {
|
||||||
|
return Promise.of(cachedDeviceLinks)
|
||||||
|
} else {
|
||||||
|
return getUserProfiles(updatees, server, true).map(SnodeAPI.sharedContext) { data ->
|
||||||
|
data.map dataMap@ { node ->
|
||||||
|
val publicKey = node["username"] as String
|
||||||
|
val annotations = node["annotations"] as List<Map<*, *>>
|
||||||
|
val deviceLinksAnnotation = annotations.find {
|
||||||
|
annotation -> (annotation["type"] as String) == deviceLinkType
|
||||||
|
} ?: return@dataMap DeviceLinkUpdateResult.Success(publicKey, setOf())
|
||||||
|
val value = deviceLinksAnnotation["value"] as Map<*, *>
|
||||||
|
val deviceLinksAsJSON = value["authorisations"] as List<Map<*, *>>
|
||||||
|
val deviceLinks = deviceLinksAsJSON.mapNotNull { deviceLinkAsJSON ->
|
||||||
|
try {
|
||||||
|
val masterPublicKey = deviceLinkAsJSON["primaryDevicePubKey"] as String
|
||||||
|
val slavePublicKey = deviceLinkAsJSON["secondaryDevicePubKey"] as String
|
||||||
|
var requestSignature: ByteArray? = null
|
||||||
|
var authorizationSignature: ByteArray? = null
|
||||||
|
if (deviceLinkAsJSON["requestSignature"] != null) {
|
||||||
|
val base64EncodedSignature = deviceLinkAsJSON["requestSignature"] as String
|
||||||
|
requestSignature = Base64.decode(base64EncodedSignature)
|
||||||
|
}
|
||||||
|
if (deviceLinkAsJSON["grantSignature"] != null) {
|
||||||
|
val base64EncodedSignature = deviceLinkAsJSON["grantSignature"] as String
|
||||||
|
authorizationSignature = Base64.decode(base64EncodedSignature)
|
||||||
|
}
|
||||||
|
val deviceLink = DeviceLink(masterPublicKey, slavePublicKey, requestSignature, authorizationSignature)
|
||||||
|
val isValid = deviceLink.verify()
|
||||||
|
if (!isValid) {
|
||||||
|
Log.d("Loki", "Ignoring invalid device link: $deviceLinkAsJSON.")
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
deviceLink
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to parse device links for $publicKey from $deviceLinkAsJSON due to error: $e.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
DeviceLinkUpdateResult.Success(publicKey, deviceLinks)
|
||||||
|
}
|
||||||
|
}.recover { e ->
|
||||||
|
publicKeys.map { DeviceLinkUpdateResult.Failure(it, e) }
|
||||||
|
}.success { updateResults ->
|
||||||
|
for (updateResult in updateResults) {
|
||||||
|
if (updateResult is DeviceLinkUpdateResult.Success) {
|
||||||
|
database.clearDeviceLinks(updateResult.publicKey)
|
||||||
|
updateResult.deviceLinks.forEach { database.addDeviceLink(it) }
|
||||||
|
} else {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.map(SnodeAPI.sharedContext) { updateResults ->
|
||||||
|
val deviceLinks = mutableListOf<DeviceLink>()
|
||||||
|
for (updateResult in updateResults) {
|
||||||
|
when (updateResult) {
|
||||||
|
is DeviceLinkUpdateResult.Success -> {
|
||||||
|
lastDeviceLinkUpdate[updateResult.publicKey] = now
|
||||||
|
deviceLinks.addAll(updateResult.deviceLinks)
|
||||||
|
}
|
||||||
|
is DeviceLinkUpdateResult.Failure -> {
|
||||||
|
if (updateResult.error is SnodeAPI.Error.ParsingFailed) {
|
||||||
|
lastDeviceLinkUpdate[updateResult.publicKey] = now // Don't infinitely update in case of a parsing failure
|
||||||
|
}
|
||||||
|
deviceLinks.addAll(database.getDeviceLinks(updateResult.publicKey)) // Fall back on cached device links in case of a failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Updatees that didn't show up in the response provided by the file server are assumed to not have any device links
|
||||||
|
val excludedUpdatees = updatees.filter { updatee ->
|
||||||
|
updateResults.find { updateResult ->
|
||||||
|
when (updateResult) {
|
||||||
|
is DeviceLinkUpdateResult.Success -> updateResult.publicKey == updatee
|
||||||
|
is DeviceLinkUpdateResult.Failure -> updateResult.publicKey == updatee
|
||||||
|
}
|
||||||
|
} == null
|
||||||
|
}
|
||||||
|
excludedUpdatees.forEach {
|
||||||
|
lastDeviceLinkUpdate[it] = now
|
||||||
|
}
|
||||||
|
deviceLinks.union(cachedDeviceLinks)
|
||||||
|
}.recover {
|
||||||
|
publicKeys.flatMap { database.getDeviceLinks(it) }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeviceLinks(deviceLinks: Set<DeviceLink>): Promise<Unit, Exception> {
|
||||||
|
return Promise.of(Unit)
|
||||||
|
/*
|
||||||
|
val isMaster = deviceLinks.find { it.masterPublicKey == userPublicKey } != null
|
||||||
|
val deviceLinksAsJSON = deviceLinks.map { it.toJSON() }
|
||||||
|
val value = if (deviceLinks.isNotEmpty()) mapOf( "isPrimary" to isMaster, "authorisations" to deviceLinksAsJSON ) else null
|
||||||
|
val annotation = mapOf( "type" to deviceLinkType, "value" to value )
|
||||||
|
val parameters = mapOf( "annotations" to listOf( annotation ) )
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
execute(HTTPVerb.PATCH, server, "/users/me", parameters = parameters)
|
||||||
|
}.map { Unit }
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
|
||||||
|
return Promise.of(Unit)
|
||||||
|
/*
|
||||||
|
Log.d("Loki", "Updating device links.")
|
||||||
|
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
|
||||||
|
val mutableDeviceLinks = deviceLinks.toMutableSet()
|
||||||
|
mutableDeviceLinks.add(deviceLink)
|
||||||
|
setDeviceLinks(mutableDeviceLinks)
|
||||||
|
}.success {
|
||||||
|
database.addDeviceLink(deviceLink)
|
||||||
|
}.map { Unit }
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
|
||||||
|
return Promise.of(Unit)
|
||||||
|
/*
|
||||||
|
Log.d("Loki", "Updating device links.")
|
||||||
|
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
|
||||||
|
val mutableDeviceLinks = deviceLinks.toMutableSet()
|
||||||
|
mutableDeviceLinks.remove(deviceLink)
|
||||||
|
setDeviceLinks(mutableDeviceLinks)
|
||||||
|
}.success {
|
||||||
|
database.removeDeviceLink(deviceLink)
|
||||||
|
}.map { Unit }
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Open Group Server Public Key
|
||||||
|
fun getPublicKeyForOpenGroupServer(openGroupServer: String): Promise<String, Exception> {
|
||||||
|
val publicKey = database.getOpenGroupPublicKey(openGroupServer)
|
||||||
|
if (publicKey != null && PublicKeyValidation.isValid(publicKey, 64, false)) {
|
||||||
|
return Promise.of(publicKey)
|
||||||
|
} else {
|
||||||
|
val url = "$server/loki/v1/getOpenGroupKey/${URL(openGroupServer).host}"
|
||||||
|
val request = Request.Builder().url(url)
|
||||||
|
request.addHeader("Content-Type", "application/json")
|
||||||
|
request.addHeader("Authorization", "Bearer loki") // Tokenless request; use a dummy token
|
||||||
|
return OnionRequestAPI.sendOnionRequest(request.build(), server, fileServerPublicKey).map { json ->
|
||||||
|
try {
|
||||||
|
val bodyAsString = json["data"] as String
|
||||||
|
val body = JsonUtil.fromJson(bodyAsString)
|
||||||
|
val base64EncodedPublicKey = body.get("data").asText()
|
||||||
|
val prefixedPublicKey = Base64.decode(base64EncodedPublicKey)
|
||||||
|
val hexEncodedPrefixedPublicKey = prefixedPublicKey.toHexString()
|
||||||
|
val result = hexEncodedPrefixedPublicKey.removing05PrefixIfNeeded()
|
||||||
|
database.setOpenGroupPublicKey(openGroupServer, result)
|
||||||
|
result
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse open group public key from: $json.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,17 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
class AttachmentDownloadJob: Job {
|
class AttachmentDownloadJob: Job {
|
||||||
|
override var delegate: JobDelegate? = null
|
||||||
|
override var id: String? = null
|
||||||
|
override var failureCount: Int = 0
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
override val maxFailureCount: Int = 100
|
||||||
|
companion object {
|
||||||
|
val collection: String = "AttachmentDownloadJobCollection"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,17 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
class AttachmentUploadJob : Job {
|
class AttachmentUploadJob : Job {
|
||||||
|
override var delegate: JobDelegate? = null
|
||||||
|
override var id: String? = null
|
||||||
|
override var failureCount: Int = 0
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
override val maxFailureCount: Int = 20
|
||||||
|
companion object {
|
||||||
|
val collection: String = "AttachmentUploadJobCollection"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,11 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
|
var delegate: JobDelegate?
|
||||||
|
var id: String?
|
||||||
|
var failureCount: Int
|
||||||
|
|
||||||
|
val maxFailureCount: Int
|
||||||
|
|
||||||
|
fun execute()
|
||||||
}
|
}
|
@ -1,4 +1,7 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
interface JobDelegate {
|
interface JobDelegate {
|
||||||
|
fun handleJobSucceeded(job: Job)
|
||||||
|
fun handleJobFailed(job: Job, error: Exception)
|
||||||
|
fun handleJobFailedPermanently(job: Job, error: Exception)
|
||||||
}
|
}
|
@ -1,4 +1,89 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import java.util.Timer
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
|
||||||
class JobQueue : JobDelegate {
|
class JobQueue : JobDelegate {
|
||||||
|
private var hasResumedPendingJobs = false // Just for debugging
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val shared: JobQueue by lazy { JobQueue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(job: Job) {
|
||||||
|
addWithoutExecuting(job)
|
||||||
|
job.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addWithoutExecuting(job: Job) {
|
||||||
|
job.id = System.currentTimeMillis().toString()
|
||||||
|
Configuration.shared.storage.persist(job)
|
||||||
|
job.delegate = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumePendingJobs() {
|
||||||
|
if (hasResumedPendingJobs) {
|
||||||
|
Log.d("Loki", "resumePendingJobs() should only be called once.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasResumedPendingJobs = true
|
||||||
|
val allJobTypes = listOf(AttachmentDownloadJob.collection, AttachmentDownloadJob.collection, MessageReceiveJob.collection, MessageSendJob.collection, NotifyPNServerJob.collection)
|
||||||
|
allJobTypes.forEach { type ->
|
||||||
|
val allPendingJobs = Configuration.shared.storage.getAllPendingJobs(type)
|
||||||
|
allPendingJobs.sortedBy { it.id }.forEach { job ->
|
||||||
|
Log.i("Jobs", "Resuming pending job of type: ${job::class.simpleName}.")
|
||||||
|
job.delegate = this
|
||||||
|
job.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleJobSucceeded(job: Job) {
|
||||||
|
Configuration.shared.storage.markJobAsSucceeded(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleJobFailed(job: Job, error: Exception) {
|
||||||
|
job.failureCount += 1
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
if (storage.isJobCanceled(job)) { return Log.i("Jobs", "${job::class.simpleName} canceled.")}
|
||||||
|
storage.persist(job)
|
||||||
|
if (job.failureCount == job.maxFailureCount) {
|
||||||
|
storage.markJobAsFailed(job)
|
||||||
|
} else {
|
||||||
|
val retryInterval = getRetryInterval(job)
|
||||||
|
Log.i("Jobs", "${job::class.simpleName} failed; scheduling retry (failure count is ${job.failureCount}).")
|
||||||
|
Timer().schedule(delay = retryInterval) {
|
||||||
|
Log.i("Jobs", "Retrying ${job::class.simpleName}.")
|
||||||
|
job.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleJobFailedPermanently(job: Job, error: Exception) {
|
||||||
|
job.failureCount += 1
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
storage.persist(job)
|
||||||
|
storage.markJobAsFailed(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRetryInterval(job: Job): Long {
|
||||||
|
// Arbitrary backoff factor...
|
||||||
|
// try 1 delay: 0ms
|
||||||
|
// try 2 delay: 190ms
|
||||||
|
// ...
|
||||||
|
// try 5 delay: 1300ms
|
||||||
|
// ...
|
||||||
|
// try 11 delay: 61310ms
|
||||||
|
val backoffFactor = 1.9
|
||||||
|
val maxBackoff = (60 * 60 * 1000).toDouble()
|
||||||
|
return (100 * min(maxBackoff, backoffFactor.pow(job.failureCount))).roundToLong()
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,17 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
class MessageReceiveJob : Job {
|
class MessageReceiveJob : Job {
|
||||||
|
override var delegate: JobDelegate? = null
|
||||||
|
override var id: String? = null
|
||||||
|
override var failureCount: Int = 0
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
override val maxFailureCount: Int = 10
|
||||||
|
companion object {
|
||||||
|
val collection: String = "MessageReceiveJobCollection"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,17 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
class MessageSendJob : Job {
|
class MessageSendJob : Job {
|
||||||
|
override var delegate: JobDelegate? = null
|
||||||
|
override var id: String? = null
|
||||||
|
override var failureCount: Int = 0
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
override val maxFailureCount: Int = 10
|
||||||
|
companion object {
|
||||||
|
val collection: String = "MessageSendJobCollection"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,57 @@
|
|||||||
package org.session.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
class NotifyPNServerJob : Job {
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
|
||||||
|
import org.session.libsession.snode.SnodeMessage
|
||||||
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||||
|
|
||||||
|
class NotifyPNServerJob(val message: SnodeMessage) : Job {
|
||||||
|
override var delegate: JobDelegate? = null
|
||||||
|
override var id: String? = null
|
||||||
|
override var failureCount: Int = 0
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
override val maxFailureCount: Int = 20
|
||||||
|
companion object {
|
||||||
|
val collection: String = "NotifyPNServerJobCollection"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running
|
||||||
|
override fun execute() {
|
||||||
|
val server = PushNotificationAPI.server
|
||||||
|
val parameters = mapOf( "data" to message.data, "send_to" to message.recipient )
|
||||||
|
val url = "${server}/notify"
|
||||||
|
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||||
|
val request = Request.Builder().url(url).post(body)
|
||||||
|
retryIfNeeded(4) {
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||||
|
val code = json["code"] as? Int
|
||||||
|
if (code == null || code == 0) {
|
||||||
|
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.")
|
||||||
|
}
|
||||||
|
}.fail { exception ->
|
||||||
|
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: $exception.")
|
||||||
|
}
|
||||||
|
}.success {
|
||||||
|
handleSuccess()
|
||||||
|
}. fail {
|
||||||
|
handleFailure(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSuccess() {
|
||||||
|
delegate?.handleJobSucceeded(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFailure(error: Exception) {
|
||||||
|
delegate?.handleJobFailed(this, error)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,5 +1,12 @@
|
|||||||
package org.session.messaging.messages
|
package org.session.libsession.messaging.messages
|
||||||
|
|
||||||
enum class Destination {
|
sealed class Destination {
|
||||||
|
|
||||||
|
class Contact(val publicKey: String) : Destination()
|
||||||
|
class ClosedGroup(val groupPublicKey: String) : Destination()
|
||||||
|
class OpenGroup(val channel: Long, val server: String) : Destination()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
//TODO need to implement the equivalent to TSThread and then implement from(...)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,5 +1,26 @@
|
|||||||
package org.session.messaging.messages
|
package org.session.libsession.messaging.messages
|
||||||
|
|
||||||
open class Message {
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
abstract class Message {
|
||||||
|
|
||||||
|
var id: String? = null
|
||||||
|
var threadID: String? = null
|
||||||
|
var sentTimestamp: Long? = null
|
||||||
|
var receivedTimestamp: Long? = null
|
||||||
|
var recipient: String? = null
|
||||||
|
var sender: String? = null
|
||||||
|
var groupPublicKey: String? = null
|
||||||
|
var openGroupServerMessageID: Long? = null
|
||||||
|
val ttl: Long = 2 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// validation
|
||||||
|
open fun isValid(): Boolean {
|
||||||
|
sentTimestamp = if (sentTimestamp!! > 0) sentTimestamp else return false
|
||||||
|
receivedTimestamp = if (receivedTimestamp!! > 0) receivedTimestamp else return false
|
||||||
|
return sender != null && recipient != null
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun toProto(): SignalServiceProtos.Content?
|
||||||
|
|
||||||
}
|
}
|
@ -1,4 +1,154 @@
|
|||||||
package org.session.messaging.messages.control
|
package org.session.libsession.messaging.messages.control
|
||||||
|
|
||||||
class ClosedGroupUpdate : ControlMessage() {
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey
|
||||||
|
|
||||||
|
class ClosedGroupUpdate() : ControlMessage() {
|
||||||
|
|
||||||
|
var kind: Kind? = null
|
||||||
|
|
||||||
|
// Kind enum
|
||||||
|
sealed class Kind {
|
||||||
|
class New(val groupPublicKey: ByteArray, val name: String, val groupPrivateKey: ByteArray, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
||||||
|
class Info(val groupPublicKey: ByteArray, val name: String, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
||||||
|
class SenderKeyRequest(val groupPublicKey: ByteArray) : Kind()
|
||||||
|
class SenderKey(val groupPublicKey: ByteArray, val senderKey: org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey) : Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ClosedGroupUpdate"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupUpdate? {
|
||||||
|
val closedGroupUpdateProto = proto.dataMessage?.closedGroupUpdate ?: return null
|
||||||
|
val groupPublicKey = closedGroupUpdateProto.groupPublicKey
|
||||||
|
var kind: Kind
|
||||||
|
when(closedGroupUpdateProto.type) {
|
||||||
|
SignalServiceProtos.ClosedGroupUpdate.Type.NEW -> {
|
||||||
|
val name = closedGroupUpdateProto.name ?: return null
|
||||||
|
val groupPrivateKey = closedGroupUpdateProto.groupPrivateKey ?: return null
|
||||||
|
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
|
||||||
|
kind = Kind.New(
|
||||||
|
groupPublicKey = groupPublicKey.toByteArray(),
|
||||||
|
name = name,
|
||||||
|
groupPrivateKey = groupPrivateKey.toByteArray(),
|
||||||
|
senderKeys = senderKeys,
|
||||||
|
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
|
||||||
|
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SignalServiceProtos.ClosedGroupUpdate.Type.INFO -> {
|
||||||
|
val name = closedGroupUpdateProto.name ?: return null
|
||||||
|
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
|
||||||
|
kind = Kind.Info(
|
||||||
|
groupPublicKey = groupPublicKey.toByteArray(),
|
||||||
|
name = name,
|
||||||
|
senderKeys = senderKeys,
|
||||||
|
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
|
||||||
|
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST -> {
|
||||||
|
kind = Kind.SenderKeyRequest(groupPublicKey = groupPublicKey.toByteArray())
|
||||||
|
}
|
||||||
|
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY -> {
|
||||||
|
val senderKeyProto = closedGroupUpdateProto.senderKeysList?.first() ?: return null
|
||||||
|
kind = Kind.SenderKey(
|
||||||
|
groupPublicKey = groupPublicKey.toByteArray(),
|
||||||
|
senderKey = ClosedGroupSenderKey.fromProto(senderKeyProto)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ClosedGroupUpdate(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// constructor
|
||||||
|
internal constructor(kind: Kind?) : this() {
|
||||||
|
this.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
val kind = kind ?: return false
|
||||||
|
when(kind) {
|
||||||
|
is Kind.New -> {
|
||||||
|
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.groupPrivateKey.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
|
||||||
|
}
|
||||||
|
is Kind.Info -> {
|
||||||
|
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
|
||||||
|
}
|
||||||
|
is Kind.SenderKeyRequest -> {
|
||||||
|
return !kind.groupPublicKey.isEmpty()
|
||||||
|
}
|
||||||
|
is Kind.SenderKey -> {
|
||||||
|
return !kind.groupPublicKey.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val kind = kind
|
||||||
|
if (kind == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdate.Builder = SignalServiceProtos.ClosedGroupUpdate.newBuilder()
|
||||||
|
when (kind) {
|
||||||
|
is Kind.New -> {
|
||||||
|
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||||
|
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.NEW
|
||||||
|
closedGroupUpdate.name = kind.name
|
||||||
|
closedGroupUpdate.groupPrivateKey = ByteString.copyFrom(kind.groupPrivateKey)
|
||||||
|
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
|
||||||
|
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
|
||||||
|
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
|
||||||
|
}
|
||||||
|
is Kind.Info -> {
|
||||||
|
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||||
|
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.INFO
|
||||||
|
closedGroupUpdate.name = kind.name
|
||||||
|
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
|
||||||
|
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
|
||||||
|
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
|
||||||
|
}
|
||||||
|
is Kind.SenderKeyRequest -> {
|
||||||
|
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||||
|
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST
|
||||||
|
}
|
||||||
|
is Kind.SenderKey -> {
|
||||||
|
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||||
|
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY
|
||||||
|
closedGroupUpdate.addAllSenderKeys(listOf( kind.senderKey.toProto() ))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||||
|
dataMessageProto.closedGroupUpdate = closedGroupUpdate.build()
|
||||||
|
contentProto.dataMessage = dataMessageProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// extension functions to class ClosedGroupSenderKey
|
||||||
|
|
||||||
|
private fun ClosedGroupSenderKey.Companion.fromProto(proto: SignalServiceProtos.ClosedGroupUpdate.SenderKey): ClosedGroupSenderKey {
|
||||||
|
return ClosedGroupSenderKey(chainKey = proto.chainKey.toByteArray(), keyIndex = proto.keyIndex, publicKey = proto.publicKey.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ClosedGroupSenderKey.toProto(): SignalServiceProtos.ClosedGroupUpdate.SenderKey {
|
||||||
|
val proto = SignalServiceProtos.ClosedGroupUpdate.SenderKey.newBuilder()
|
||||||
|
proto.chainKey = ByteString.copyFrom(chainKey)
|
||||||
|
proto.keyIndex = keyIndex
|
||||||
|
proto.publicKey = ByteString.copyFrom(publicKey)
|
||||||
|
return proto.build()
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
package org.session.messaging.messages.control
|
package org.session.libsession.messaging.messages.control
|
||||||
|
|
||||||
import org.session.messaging.messages.Message
|
import org.session.libsession.messaging.messages.Message
|
||||||
|
|
||||||
open class ControlMessage : Message() {
|
abstract class ControlMessage : Message() {
|
||||||
}
|
}
|
@ -1,4 +1,51 @@
|
|||||||
package org.session.messaging.messages.control
|
package org.session.libsession.messaging.messages.control
|
||||||
|
|
||||||
class ExpirationTimerUpdate : ControlMessage() {
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class ExpirationTimerUpdate() : ControlMessage() {
|
||||||
|
|
||||||
|
var duration: Int? = 0
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ExpirationTimerUpdate"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? {
|
||||||
|
val dataMessageProto = proto.dataMessage ?: return null
|
||||||
|
val isExpirationTimerUpdate = (dataMessageProto.flags and SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0 //TODO validate that 'and' operator equivalent to Swift '&'
|
||||||
|
if (!isExpirationTimerUpdate) return null
|
||||||
|
val duration = dataMessageProto.expireTimer
|
||||||
|
return ExpirationTimerUpdate(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(duration: Int) : this() {
|
||||||
|
this.duration = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
return duration != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val duration = duration
|
||||||
|
if (duration == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||||
|
dataMessageProto.flags = SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE
|
||||||
|
dataMessageProto.expireTimer = duration
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
try {
|
||||||
|
contentProto.dataMessage = dataMessageProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,53 @@
|
|||||||
package org.session.messaging.messages.control
|
package org.session.libsession.messaging.messages.control
|
||||||
|
|
||||||
class ReadReceipt : ControlMessage() {
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class ReadReceipt() : ControlMessage() {
|
||||||
|
|
||||||
|
var timestamps: LongArray? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ReadReceipt"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): ReadReceipt? {
|
||||||
|
val receiptProto = proto.receiptMessage ?: return null
|
||||||
|
if (receiptProto.type != SignalServiceProtos.ReceiptMessage.Type.READ) return null
|
||||||
|
val timestamps = receiptProto.timestampList
|
||||||
|
if (timestamps.isEmpty()) return null
|
||||||
|
return ReadReceipt(timestamps = timestamps.toLongArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(timestamps: LongArray?) : this() {
|
||||||
|
this.timestamps = timestamps
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
val timestamps = timestamps ?: return false
|
||||||
|
if (timestamps.isNotEmpty()) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val timestamps = timestamps
|
||||||
|
if (timestamps == null) {
|
||||||
|
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val receiptProto = SignalServiceProtos.ReceiptMessage.newBuilder()
|
||||||
|
receiptProto.type = SignalServiceProtos.ReceiptMessage.Type.READ
|
||||||
|
receiptProto.addAllTimestamp(timestamps.asIterable())
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
try {
|
||||||
|
contentProto.receiptMessage = receiptProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,75 @@
|
|||||||
package org.session.messaging.messages.control
|
package org.session.libsession.messaging.messages.control
|
||||||
|
|
||||||
class TypingIndicator : ControlMessage() {
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class TypingIndicator() : ControlMessage() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "TypingIndicator"
|
||||||
|
|
||||||
|
//val ttl: 30 * 1000 //TODO
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): TypingIndicator? {
|
||||||
|
val typingIndicatorProto = proto.typingMessage ?: return null
|
||||||
|
val kind = Kind.fromProto(typingIndicatorProto.action)
|
||||||
|
return TypingIndicator(kind = kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind enum
|
||||||
|
enum class Kind {
|
||||||
|
STARTED,
|
||||||
|
STOPPED,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun fromProto(proto: SignalServiceProtos.TypingMessage.Action): Kind =
|
||||||
|
when (proto) {
|
||||||
|
SignalServiceProtos.TypingMessage.Action.STARTED -> STARTED
|
||||||
|
SignalServiceProtos.TypingMessage.Action.STOPPED -> STOPPED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toProto(): SignalServiceProtos.TypingMessage.Action {
|
||||||
|
when (this) {
|
||||||
|
STARTED -> return SignalServiceProtos.TypingMessage.Action.STARTED
|
||||||
|
STOPPED -> return SignalServiceProtos.TypingMessage.Action.STOPPED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind: Kind? = null
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(kind: Kind) : this() {
|
||||||
|
this.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
return kind != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val timestamp = sentTimestamp
|
||||||
|
val kind = kind
|
||||||
|
if (timestamp == null || kind == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val typingIndicatorProto = SignalServiceProtos.TypingMessage.newBuilder()
|
||||||
|
typingIndicatorProto.timestamp = timestamp
|
||||||
|
typingIndicatorProto.action = kind.toProto()
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
try {
|
||||||
|
contentProto.typingMessage = typingIndicatorProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,6 +1,35 @@
|
|||||||
package org.session.messaging.messages.control.unused
|
package org.session.libsession.messaging.messages.control.unused
|
||||||
|
|
||||||
import org.session.messaging.messages.control.ControlMessage
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsession.messaging.messages.control.ControlMessage
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
class NullMessage : ControlMessage() {
|
class NullMessage() : ControlMessage() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "NullMessage"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): NullMessage? {
|
||||||
|
if (proto.nullMessage == null) return null
|
||||||
|
return NullMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
|
||||||
|
val sr = SecureRandom()
|
||||||
|
val paddingSize = sr.nextInt(512)
|
||||||
|
val padding = ByteArray(paddingSize)
|
||||||
|
nullMessageProto.padding = ByteString.copyFrom(padding)
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
try {
|
||||||
|
contentProto.nullMessage = nullMessageProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct null message proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,6 +1,83 @@
|
|||||||
package org.session.messaging.messages.control.unused
|
package org.session.libsession.messaging.messages.control.unused
|
||||||
|
|
||||||
import org.session.messaging.messages.control.ControlMessage
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsession.messaging.messages.control.ControlMessage
|
||||||
|
import org.session.libsignal.libsignal.IdentityKey
|
||||||
|
import org.session.libsignal.libsignal.ecc.DjbECPublicKey
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.libsignal.state.PreKeyBundle
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
class SessionRequest : ControlMessage() {
|
class SessionRequest() : ControlMessage() {
|
||||||
|
|
||||||
|
var preKeyBundle: PreKeyBundle? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SessionRequest"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): SessionRequest? {
|
||||||
|
if (proto.nullMessage == null) return null
|
||||||
|
val preKeyBundleProto = proto.preKeyBundleMessage ?: return null
|
||||||
|
var registrationID: Int = 0
|
||||||
|
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID() //TODO no implementation for getOrGenerateRegistrationID yet
|
||||||
|
//TODO just confirm if the above code does the equivalent to swift below:
|
||||||
|
/*iOS code: Configuration.shared.storage.with { transaction in
|
||||||
|
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID(using: transaction)
|
||||||
|
}*/
|
||||||
|
val preKeyBundle = PreKeyBundle(
|
||||||
|
registrationID,
|
||||||
|
1,
|
||||||
|
preKeyBundleProto.preKeyId,
|
||||||
|
DjbECPublicKey(preKeyBundleProto.preKey.toByteArray()),
|
||||||
|
preKeyBundleProto.signedKeyId,
|
||||||
|
DjbECPublicKey(preKeyBundleProto.signedKey.toByteArray()),
|
||||||
|
preKeyBundleProto.signature.toByteArray(),
|
||||||
|
IdentityKey(DjbECPublicKey(preKeyBundleProto.identityKey.toByteArray()))
|
||||||
|
)
|
||||||
|
return SessionRequest(preKeyBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(preKeyBundle: PreKeyBundle) : this() {
|
||||||
|
this.preKeyBundle = preKeyBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
return preKeyBundle != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
val preKeyBundle = preKeyBundle
|
||||||
|
if (preKeyBundle == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct session request proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
|
||||||
|
val sr = SecureRandom()
|
||||||
|
val paddingSize = sr.nextInt(512)
|
||||||
|
val padding = ByteArray(paddingSize)
|
||||||
|
nullMessageProto.padding = ByteString.copyFrom(padding)
|
||||||
|
val preKeyBundleProto = SignalServiceProtos.PreKeyBundleMessage.newBuilder()
|
||||||
|
preKeyBundleProto.identityKey = ByteString.copyFrom(preKeyBundle.identityKey.publicKey.serialize())
|
||||||
|
preKeyBundleProto.deviceId = preKeyBundle.deviceId
|
||||||
|
preKeyBundleProto.preKeyId = preKeyBundle.preKeyId
|
||||||
|
preKeyBundleProto.preKey = ByteString.copyFrom(preKeyBundle.preKey.serialize())
|
||||||
|
preKeyBundleProto.signedKeyId = preKeyBundle.signedPreKeyId
|
||||||
|
preKeyBundleProto.signedKey = ByteString.copyFrom(preKeyBundle.signedPreKey.serialize())
|
||||||
|
preKeyBundleProto.signature = ByteString.copyFrom(preKeyBundle.signedPreKeySignature)
|
||||||
|
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
try {
|
||||||
|
contentProto.nullMessage = nullMessageProto.build()
|
||||||
|
contentProto.preKeyBundleMessage = preKeyBundleProto.build()
|
||||||
|
return contentProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct session request proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
|
import android.util.Size
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class Attachment : VisibleMessageProto<SignalServiceProtos.AttachmentPointer?>() {
|
||||||
|
|
||||||
|
var fileName: String? = null
|
||||||
|
var contentType: String? = null
|
||||||
|
var key: ByteArray? = null
|
||||||
|
var digest: ByteArray? = null
|
||||||
|
var kind: Kind? = null
|
||||||
|
var caption: String? = null
|
||||||
|
var size: Size? = null
|
||||||
|
var sizeInBytes: Int? = 0
|
||||||
|
var url: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromProto(proto: SignalServiceProtos.AttachmentPointer): Attachment? {
|
||||||
|
val result = Attachment()
|
||||||
|
result.fileName = proto.fileName
|
||||||
|
fun inferContentType(): String {
|
||||||
|
val fileName = result.fileName ?: return "application/octet-stream" //TODO find equivalent to OWSMimeTypeApplicationOctetStream
|
||||||
|
val fileExtension = File(fileName).extension
|
||||||
|
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
return mimeTypeMap.getMimeTypeFromExtension(fileExtension) ?: "application/octet-stream" //TODO check that it's correct
|
||||||
|
}
|
||||||
|
result.contentType = proto.contentType ?: inferContentType()
|
||||||
|
result.key = proto.key.toByteArray()
|
||||||
|
result.digest = proto.digest.toByteArray()
|
||||||
|
val kind: Kind
|
||||||
|
if (proto.hasFlags() && (proto.flags and SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { //TODO validate that 'and' operator = swift '&'
|
||||||
|
kind = Kind.VOICEMESSAGE
|
||||||
|
} else {
|
||||||
|
kind = Kind.GENERIC
|
||||||
|
}
|
||||||
|
result.kind = kind
|
||||||
|
result.caption = if (proto.hasCaption()) proto.caption else null
|
||||||
|
val size: Size
|
||||||
|
if (proto.hasWidth() && proto.width > 0 && proto.hasHeight() && proto.height > 0) {
|
||||||
|
size = Size(proto.width, proto.height)
|
||||||
|
} else {
|
||||||
|
size = Size(0,0) //TODO check that it's equivalent to swift: CGSize.zero
|
||||||
|
}
|
||||||
|
result.size = size
|
||||||
|
result.sizeInBytes = if (proto.size > 0) proto.size else null
|
||||||
|
result. url = proto.url
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Kind {
|
||||||
|
VOICEMESSAGE,
|
||||||
|
GENERIC
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
// key and digest can be nil for open group attachments
|
||||||
|
return (contentType != null && kind != null && size != null && sizeInBytes != null && url != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.AttachmentPointer? {
|
||||||
|
TODO("Not implemented")
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,16 @@
|
|||||||
package org.session.messaging.messages.visible
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
internal class Contact {
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class Contact : VisibleMessageProto<SignalServiceProtos.DataMessage.Contact?>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): Contact? {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Contact? {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,57 @@
|
|||||||
package org.session.messaging.messages.visible
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
internal class LinkPreview {
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class LinkPreview() : VisibleMessageProto<SignalServiceProtos.DataMessage.Preview?>(){
|
||||||
|
|
||||||
|
var title: String? = null
|
||||||
|
var url: String? = null
|
||||||
|
var attachmentID: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "LinkPreview"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.DataMessage.Preview): LinkPreview? {
|
||||||
|
val title = proto.title
|
||||||
|
val url = proto.url
|
||||||
|
return LinkPreview(title, url, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(title: String?, url: String, attachmentID: String?) : this() {
|
||||||
|
this.title = title
|
||||||
|
this.url = url
|
||||||
|
this.attachmentID = attachmentID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
return (title != null && url != null && attachmentID != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Preview? {
|
||||||
|
val url = url
|
||||||
|
if (url == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val linkPreviewProto = SignalServiceProtos.DataMessage.Preview.newBuilder()
|
||||||
|
linkPreviewProto.url = url
|
||||||
|
title?.let { linkPreviewProto.title = title }
|
||||||
|
val attachmentID = attachmentID
|
||||||
|
attachmentID?.let {
|
||||||
|
//TODO database stuff
|
||||||
|
}
|
||||||
|
// Build
|
||||||
|
try {
|
||||||
|
return linkPreviewProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,64 @@
|
|||||||
package org.session.messaging.messages.visible
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
internal class Profile {
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class Profile() : VisibleMessageProto<SignalServiceProtos.DataMessage?>() {
|
||||||
|
|
||||||
|
var displayName: String? = null
|
||||||
|
var profileKey: ByteArray? = null
|
||||||
|
var profilePictureURL: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "Profile"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.DataMessage): Profile? {
|
||||||
|
val profileProto = proto.profile ?: return null
|
||||||
|
val displayName = profileProto.displayName ?: return null
|
||||||
|
val profileKey = proto.profileKey
|
||||||
|
val profilePictureURL = profileProto.profilePictureURL
|
||||||
|
profileKey?.let {
|
||||||
|
val profilePictureURL = profilePictureURL
|
||||||
|
profilePictureURL?.let {
|
||||||
|
return Profile(displayName = displayName, profileKey = profileKey.toByteArray(), profilePictureURL = profilePictureURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Profile(displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() {
|
||||||
|
this.displayName = displayName
|
||||||
|
this.profileKey = profileKey
|
||||||
|
this.profilePictureURL = profilePictureURL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSSProto(): SignalServiceProtos.DataMessage? {
|
||||||
|
return this.toProto("")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.DataMessage? {
|
||||||
|
val displayName = displayName
|
||||||
|
if (displayName == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||||
|
val profileProto = SignalServiceProtos.LokiUserProfile.newBuilder()
|
||||||
|
profileProto.displayName = displayName
|
||||||
|
val profileKey = profileKey
|
||||||
|
profileKey?.let { dataMessageProto.profileKey = ByteString.copyFrom(profileKey) }
|
||||||
|
val profilePictureURL = profilePictureURL
|
||||||
|
profilePictureURL?.let { profileProto.profilePictureURL = profilePictureURL }
|
||||||
|
// Build
|
||||||
|
try {
|
||||||
|
dataMessageProto.profile = profileProto.build()
|
||||||
|
return dataMessageProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct profile proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,72 @@
|
|||||||
package org.session.messaging.messages.visible
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
internal class Quote {
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class Quote() : VisibleMessageProto<SignalServiceProtos.DataMessage.Quote?>() {
|
||||||
|
|
||||||
|
var timestamp: Long? = 0
|
||||||
|
var publicKey: String? = null
|
||||||
|
var text: String? = null
|
||||||
|
var attachmentID: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "Quote"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.DataMessage.Quote): Quote? {
|
||||||
|
val timestamp = proto.id
|
||||||
|
val publicKey = proto.author
|
||||||
|
val text = proto.text
|
||||||
|
return Quote(timestamp, publicKey, text, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//constructor
|
||||||
|
internal constructor(timestamp: Long, publicKey: String, text: String?, attachmentID: String?) : this() {
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.publicKey = publicKey
|
||||||
|
this.text = text
|
||||||
|
this.attachmentID = attachmentID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
return (timestamp != null && publicKey != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Quote? {
|
||||||
|
val timestamp = timestamp
|
||||||
|
val publicKey = publicKey
|
||||||
|
if (timestamp == null || publicKey == null) {
|
||||||
|
Log.w(TAG, "Couldn't construct quote proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val quoteProto = SignalServiceProtos.DataMessage.Quote.newBuilder()
|
||||||
|
quoteProto.id = timestamp
|
||||||
|
quoteProto.author = publicKey
|
||||||
|
text?.let { quoteProto.text = text }
|
||||||
|
addAttachmentsIfNeeded(quoteProto, transaction)
|
||||||
|
// Build
|
||||||
|
try {
|
||||||
|
return quoteProto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct quote proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addAttachmentsIfNeeded(quoteProto: SignalServiceProtos.DataMessage.Quote.Builder, transaction: String) {
|
||||||
|
val attachmentID = attachmentID ?: return
|
||||||
|
//TODO databas stuff
|
||||||
|
val quotedAttachmentProto = SignalServiceProtos.DataMessage.Quote.QuotedAttachment.newBuilder()
|
||||||
|
//TODO more database related stuff
|
||||||
|
//quotedAttachmentProto.contentType =
|
||||||
|
try {
|
||||||
|
quoteProto.addAttachments(quotedAttachmentProto.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct quoted attachment proto from: $this")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,6 +1,102 @@
|
|||||||
package org.session.messaging.messages.visible
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
import org.session.messaging.messages.Message
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
class VisibleMessage() : VisibleMessageProto<SignalServiceProtos.Content?>() {
|
||||||
|
|
||||||
|
var text: String? = null
|
||||||
|
var attachmentIDs = ArrayList<String>()
|
||||||
|
var quote: Quote? = null
|
||||||
|
var linkPreview: LinkPreview? = null
|
||||||
|
var contact: Contact? = null
|
||||||
|
var profile: Profile? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "BaseVisibleMessage"
|
||||||
|
|
||||||
|
fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? {
|
||||||
|
val dataMessage = proto.dataMessage ?: return null
|
||||||
|
val result = VisibleMessage()
|
||||||
|
result.text = dataMessage.body
|
||||||
|
// Attachments are handled in MessageReceiver
|
||||||
|
val quoteProto = dataMessage.quote
|
||||||
|
quoteProto?.let {
|
||||||
|
val quote = Quote.fromProto(quoteProto)
|
||||||
|
quote?.let { result.quote = quote }
|
||||||
|
}
|
||||||
|
val linkPreviewProto = dataMessage.previewList.first()
|
||||||
|
linkPreviewProto?.let {
|
||||||
|
val linkPreview = LinkPreview.fromProto(linkPreviewProto)
|
||||||
|
linkPreview?.let { result.linkPreview = linkPreview }
|
||||||
|
}
|
||||||
|
// TODO Contact
|
||||||
|
val profile = Profile.fromProto(dataMessage)
|
||||||
|
profile?.let { result.profile = profile }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation
|
||||||
|
override fun isValid(): Boolean {
|
||||||
|
if (!super.isValid()) return false
|
||||||
|
if (attachmentIDs.isNotEmpty()) return true
|
||||||
|
val text = text?.trim() ?: return false
|
||||||
|
if (text.isNotEmpty()) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toProto(transaction: String): SignalServiceProtos.Content? {
|
||||||
|
val proto = SignalServiceProtos.Content.newBuilder()
|
||||||
|
var attachmentIDs = this.attachmentIDs
|
||||||
|
val dataMessage: SignalServiceProtos.DataMessage.Builder
|
||||||
|
// Profile
|
||||||
|
val profile = profile
|
||||||
|
val profileProto = profile?.toSSProto()
|
||||||
|
if (profileProto != null) {
|
||||||
|
dataMessage = profileProto.toBuilder()
|
||||||
|
} else {
|
||||||
|
dataMessage = SignalServiceProtos.DataMessage.newBuilder()
|
||||||
|
}
|
||||||
|
// Text
|
||||||
|
text?.let { dataMessage.body = text }
|
||||||
|
// Quote
|
||||||
|
val quotedAttachmentID = quote?.attachmentID
|
||||||
|
quotedAttachmentID?.let {
|
||||||
|
val index = attachmentIDs.indexOf(quotedAttachmentID)
|
||||||
|
if (index >= 0) { attachmentIDs.removeAt(index) }
|
||||||
|
}
|
||||||
|
val quote = quote
|
||||||
|
quote?.let {
|
||||||
|
val quoteProto = quote.toProto(transaction)
|
||||||
|
if (quoteProto != null) dataMessage.quote = quoteProto
|
||||||
|
}
|
||||||
|
//Link preview
|
||||||
|
val linkPreviewAttachmentID = linkPreview?.attachmentID
|
||||||
|
linkPreviewAttachmentID?.let {
|
||||||
|
val index = attachmentIDs.indexOf(quotedAttachmentID)
|
||||||
|
if (index >= 0) { attachmentIDs.removeAt(index) }
|
||||||
|
}
|
||||||
|
val linkPreview = linkPreview
|
||||||
|
linkPreview?.let {
|
||||||
|
val linkPreviewProto = linkPreview.toProto(transaction)
|
||||||
|
linkPreviewProto?.let {
|
||||||
|
dataMessage.addAllPreview(listOf(linkPreviewProto))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Attachments
|
||||||
|
// TODO I'm blocking on that one...
|
||||||
|
//swift: let attachments = attachmentIDs.compactMap { TSAttachmentStream.fetch(uniqueId: $0, transaction: transaction) }
|
||||||
|
|
||||||
|
// TODO Contact
|
||||||
|
// Build
|
||||||
|
try {
|
||||||
|
proto.dataMessage = dataMessage.build()
|
||||||
|
return proto.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Couldn't construct visible message proto from: $this")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class VisibleMessage : Message() {
|
|
||||||
}
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package org.session.libsession.messaging.messages.visible
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.messages.Message
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
|
||||||
|
abstract class VisibleMessageProto<out T: com.google.protobuf.MessageOrBuilder?> : Message() {
|
||||||
|
|
||||||
|
abstract fun toProto(transaction: String): T
|
||||||
|
|
||||||
|
final override fun toProto(): SignalServiceProtos.Content? {
|
||||||
|
//we don't need to implement this method in subclasses
|
||||||
|
//TODO it just needs an equivalent to swift: preconditionFailure("Use toProto(using:) if that exists...
|
||||||
|
TODO("Not implemented")
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +0,0 @@
|
|||||||
package org.session.messaging.messages.visible.attachments
|
|
||||||
|
|
||||||
internal class Attachment {
|
|
||||||
}
|
|
@ -0,0 +1,37 @@
|
|||||||
|
package org.session.libsession.messaging.opengroups
|
||||||
|
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
|
||||||
|
public data class OpenGroup(
|
||||||
|
public val channel: Long,
|
||||||
|
private val serverURL: String,
|
||||||
|
public val displayName: String,
|
||||||
|
public val isDeletable: Boolean
|
||||||
|
) {
|
||||||
|
public val server get() = serverURL.toLowerCase()
|
||||||
|
public val id get() = getId(channel, server)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@JvmStatic fun getId(channel: Long, server: String): String {
|
||||||
|
return "$server.$channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic fun fromJSON(jsonAsString: String): OpenGroup? {
|
||||||
|
try {
|
||||||
|
val json = JsonUtil.fromJson(jsonAsString)
|
||||||
|
val channel = json.get("channel").asLong()
|
||||||
|
val server = json.get("server").asText().toLowerCase()
|
||||||
|
val displayName = json.get("displayName").asText()
|
||||||
|
val isDeletable = json.get("isDeletable").asBoolean()
|
||||||
|
return OpenGroup(channel, server, displayName, isDeletable)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun toJSON(): Map<String, Any> {
|
||||||
|
return mapOf( "channel" to channel, "server" to server, "displayName" to displayName, "isDeletable" to isDeletable )
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,381 @@
|
|||||||
|
package org.session.libsession.messaging.opengroups
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Kovenant
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.deferred
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import nl.komponents.kovenant.then
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.utilities.DotNetAPI
|
||||||
|
import org.session.libsession.messaging.fileserver.FileServerAPI
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.internal.util.Hex
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.utilities.DownloadUtilities
|
||||||
|
import org.session.libsignal.service.loki.utilities.createContext
|
||||||
|
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
|
||||||
|
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
object OpenGroupAPI: DotNetAPI() {
|
||||||
|
|
||||||
|
private val moderators: HashMap<String, HashMap<Long, Set<String>>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs)
|
||||||
|
val sharedContext = Kovenant.createContext("LokiPublicChatAPISharedContext")
|
||||||
|
|
||||||
|
// region Settings
|
||||||
|
private val fallbackBatchCount = 64
|
||||||
|
private val maxRetryCount = 8
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Convenience
|
||||||
|
private val channelInfoType = "net.patter-app.settings"
|
||||||
|
private val attachmentType = "net.app.core.oembed"
|
||||||
|
@JvmStatic
|
||||||
|
public val openGroupMessageType = "network.loki.messenger.publicChat"
|
||||||
|
@JvmStatic
|
||||||
|
public val profilePictureType = "network.loki.messenger.avatar"
|
||||||
|
|
||||||
|
fun getDefaultChats(): List<OpenGroup> {
|
||||||
|
return listOf() // Don't auto-join any open groups right now
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun isUserModerator(hexEncodedPublicKey: String, channel: Long, server: String): Boolean {
|
||||||
|
if (moderators[server] != null && moderators[server]!![channel] != null) {
|
||||||
|
return moderators[server]!![channel]!!.contains(hexEncodedPublicKey)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Public API
|
||||||
|
public fun getMessages(channel: Long, server: String): Promise<List<OpenGroupMessage>, Exception> {
|
||||||
|
Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.")
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val parameters = mutableMapOf<String, Any>( "include_annotations" to 1 )
|
||||||
|
val lastMessageServerID = storage.getLastMessageServerID(channel, server)
|
||||||
|
if (lastMessageServerID != null) {
|
||||||
|
parameters["since_id"] = lastMessageServerID
|
||||||
|
} else {
|
||||||
|
parameters["count"] = fallbackBatchCount
|
||||||
|
parameters["include_deleted"] = 0
|
||||||
|
}
|
||||||
|
return execute(HTTPVerb.GET, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
val data = json["data"] as List<Map<*, *>>
|
||||||
|
val messages = data.mapNotNull { message ->
|
||||||
|
try {
|
||||||
|
val isDeleted = message["is_deleted"] as? Boolean ?: false
|
||||||
|
if (isDeleted) { return@mapNotNull null }
|
||||||
|
// Ignore messages without annotations
|
||||||
|
if (message["annotations"] == null) { return@mapNotNull null }
|
||||||
|
val annotation = (message["annotations"] as List<Map<*, *>>).find {
|
||||||
|
((it["type"] as? String ?: "") == openGroupMessageType) && it["value"] != null
|
||||||
|
} ?: return@mapNotNull null
|
||||||
|
val value = annotation["value"] as Map<*, *>
|
||||||
|
val serverID = message["id"] as? Long ?: (message["id"] as? Int)?.toLong() ?: (message["id"] as String).toLong()
|
||||||
|
val user = message["user"] as Map<*, *>
|
||||||
|
val publicKey = user["username"] as String
|
||||||
|
val displayName = user["name"] as? String ?: "Anonymous"
|
||||||
|
var profilePicture: OpenGroupMessage.ProfilePicture? = null
|
||||||
|
if (user["annotations"] != null) {
|
||||||
|
val profilePictureAnnotation = (user["annotations"] as List<Map< *, *>>).find {
|
||||||
|
((it["type"] as? String ?: "") == profilePictureType) && it["value"] != null
|
||||||
|
}
|
||||||
|
val profilePictureAnnotationValue = profilePictureAnnotation?.get("value") as? Map<*, *>
|
||||||
|
if (profilePictureAnnotationValue != null && profilePictureAnnotationValue["profileKey"] != null && profilePictureAnnotationValue["url"] != null) {
|
||||||
|
try {
|
||||||
|
val profileKey = Base64.decode(profilePictureAnnotationValue["profileKey"] as String)
|
||||||
|
val url = profilePictureAnnotationValue["url"] as String
|
||||||
|
profilePicture = OpenGroupMessage.ProfilePicture(profileKey, url)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Suppress("NAME_SHADOWING") val body = message["text"] as String
|
||||||
|
val timestamp = value["timestamp"] as? Long ?: (value["timestamp"] as? Int)?.toLong() ?: (value["timestamp"] as String).toLong()
|
||||||
|
var quote: OpenGroupMessage.Quote? = null
|
||||||
|
if (value["quote"] != null) {
|
||||||
|
val replyTo = message["reply_to"] as? Long ?: (message["reply_to"] as? Int)?.toLong() ?: (message["reply_to"] as String).toLong()
|
||||||
|
val quoteAnnotation = value["quote"] as? Map<*, *>
|
||||||
|
val quoteTimestamp = quoteAnnotation?.get("id") as? Long ?: (quoteAnnotation?.get("id") as? Int)?.toLong() ?: (quoteAnnotation?.get("id") as? String)?.toLong() ?: 0L
|
||||||
|
val author = quoteAnnotation?.get("author") as? String
|
||||||
|
val text = quoteAnnotation?.get("text") as? String
|
||||||
|
quote = if (quoteTimestamp > 0L && author != null && text != null) OpenGroupMessage.Quote(quoteTimestamp, author, text, replyTo) else null
|
||||||
|
}
|
||||||
|
val attachmentsAsJSON = (message["annotations"] as List<Map<*, *>>).filter {
|
||||||
|
((it["type"] as? String ?: "") == attachmentType) && it["value"] != null
|
||||||
|
}
|
||||||
|
val attachments = attachmentsAsJSON.mapNotNull { it["value"] as? Map<*, *> }.mapNotNull { attachmentAsJSON ->
|
||||||
|
try {
|
||||||
|
val kindAsString = attachmentAsJSON["lokiType"] as String
|
||||||
|
val kind = OpenGroupMessage.Attachment.Kind.values().first { it.rawValue == kindAsString }
|
||||||
|
val id = attachmentAsJSON["id"] as? Long ?: (attachmentAsJSON["id"] as? Int)?.toLong() ?: (attachmentAsJSON["id"] as String).toLong()
|
||||||
|
val contentType = attachmentAsJSON["contentType"] as String
|
||||||
|
val size = attachmentAsJSON["size"] as? Int ?: (attachmentAsJSON["size"] as? Long)?.toInt() ?: (attachmentAsJSON["size"] as String).toInt()
|
||||||
|
val fileName = attachmentAsJSON["fileName"] as String
|
||||||
|
val flags = 0
|
||||||
|
val url = attachmentAsJSON["url"] as String
|
||||||
|
val caption = attachmentAsJSON["caption"] as? String
|
||||||
|
val linkPreviewURL = attachmentAsJSON["linkPreviewUrl"] as? String
|
||||||
|
val linkPreviewTitle = attachmentAsJSON["linkPreviewTitle"] as? String
|
||||||
|
if (kind == OpenGroupMessage.Attachment.Kind.LinkPreview && (linkPreviewURL == null || linkPreviewTitle == null)) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
OpenGroupMessage.Attachment(kind, server, id, contentType, size, fileName, flags, 0, 0, caption, url, linkPreviewURL, linkPreviewTitle)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki","Couldn't parse attachment due to error: $e.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set the last message server ID here to avoid the situation where a message doesn't have a valid signature and this function is called over and over
|
||||||
|
@Suppress("NAME_SHADOWING") val lastMessageServerID = storage.getLastMessageServerID(channel, server)
|
||||||
|
if (serverID > lastMessageServerID ?: 0) { storage.setLastMessageServerID(channel, server, serverID) }
|
||||||
|
val hexEncodedSignature = value["sig"] as String
|
||||||
|
val signatureVersion = value["sigver"] as? Long ?: (value["sigver"] as? Int)?.toLong() ?: (value["sigver"] as String).toLong()
|
||||||
|
val signature = OpenGroupMessage.Signature(Hex.fromStringCondensed(hexEncodedSignature), signatureVersion)
|
||||||
|
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
|
format.timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
val dateAsString = message["created_at"] as String
|
||||||
|
val serverTimestamp = format.parse(dateAsString).time
|
||||||
|
// Verify the message
|
||||||
|
val groupMessage = OpenGroupMessage(serverID, publicKey, displayName, body, timestamp, openGroupMessageType, quote, attachments, profilePicture, signature, serverTimestamp)
|
||||||
|
if (groupMessage.hasValidSignature()) groupMessage else null
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server from: ${JsonUtil.toJson(message)}. Exception: ${exception.message}")
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
}.sortedBy { it.serverTimestamp }
|
||||||
|
messages
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse messages for open group with ID: $channel on server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun getDeletedMessageServerIDs(channel: Long, server: String): Promise<List<Long>, Exception> {
|
||||||
|
Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.")
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val parameters = mutableMapOf<String, Any>()
|
||||||
|
val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
|
||||||
|
if (lastDeletionServerID != null) {
|
||||||
|
parameters["since_id"] = lastDeletionServerID
|
||||||
|
} else {
|
||||||
|
parameters["count"] = fallbackBatchCount
|
||||||
|
}
|
||||||
|
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/deletes", parameters = parameters).then(sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
val deletedMessageServerIDs = (json["data"] as List<Map<*, *>>).mapNotNull { deletion ->
|
||||||
|
try {
|
||||||
|
val serverID = deletion["id"] as? Long ?: (deletion["id"] as? Int)?.toLong() ?: (deletion["id"] as String).toLong()
|
||||||
|
val messageServerID = deletion["message_id"] as? Long ?: (deletion["message_id"] as? Int)?.toLong() ?: (deletion["message_id"] as String).toLong()
|
||||||
|
@Suppress("NAME_SHADOWING") val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
|
||||||
|
if (serverID > (lastDeletionServerID ?: 0)) { storage.setLastDeletionServerID(channel, server, serverID) }
|
||||||
|
messageServerID
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse deleted message for open group with ID: $channel on server: $server. Exception: ${exception.message}")
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedMessageServerIDs
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse deleted messages for open group with ID: $channel on server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise<OpenGroupMessage, Exception> {
|
||||||
|
val deferred = deferred<OpenGroupMessage, Exception>()
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val userKeyPair = storage.getUserKeyPair() ?: throw Error.Generic
|
||||||
|
val userDisplayName = storage.getUserDisplayName() ?: throw Error.Generic
|
||||||
|
Thread {
|
||||||
|
val signedMessage = message.sign(userKeyPair.privateKey.serialize())
|
||||||
|
if (signedMessage == null) {
|
||||||
|
deferred.reject(Error.SigningFailed)
|
||||||
|
} else {
|
||||||
|
retryIfNeeded(maxRetryCount) {
|
||||||
|
Log.d("Loki", "Sending message to open group with ID: $channel on server: $server.")
|
||||||
|
val parameters = signedMessage.toJSON()
|
||||||
|
execute(HTTPVerb.POST, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
val data = json["data"] as Map<*, *>
|
||||||
|
val serverID = (data["id"] as? Long) ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as String).toLong()
|
||||||
|
val text = data["text"] as String
|
||||||
|
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
|
format.timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
val dateAsString = data["created_at"] as String
|
||||||
|
val timestamp = format.parse(dateAsString).time
|
||||||
|
@Suppress("NAME_SHADOWING") val message = OpenGroupMessage(serverID, userKeyPair.hexEncodedPublicKey, userDisplayName, text, timestamp, openGroupMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp)
|
||||||
|
message
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.success {
|
||||||
|
deferred.resolve(it)
|
||||||
|
}.fail {
|
||||||
|
deferred.reject(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun deleteMessage(messageServerID: Long, channel: Long, server: String, isSentByUser: Boolean): Promise<Long, Exception> {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
val isModerationRequest = !isSentByUser
|
||||||
|
Log.d("Loki", "Deleting message with ID: $messageServerID from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
|
||||||
|
val endpoint = if (isSentByUser) "channels/$channel/messages/$messageServerID" else "loki/v1/moderation/message/$messageServerID"
|
||||||
|
execute(HTTPVerb.DELETE, server, endpoint, isJSONRequired = false).then {
|
||||||
|
Log.d("Loki", "Deleted message with ID: $messageServerID from open group with ID: $channel on server: $server.")
|
||||||
|
messageServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun deleteMessages(messageServerIDs: List<Long>, channel: Long, server: String, isSentByUser: Boolean): Promise<List<Long>, Exception> {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
val isModerationRequest = !isSentByUser
|
||||||
|
val parameters = mapOf( "ids" to messageServerIDs.joinToString(",") )
|
||||||
|
Log.d("Loki", "Deleting messages with IDs: ${messageServerIDs.joinToString()} from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
|
||||||
|
val endpoint = if (isSentByUser) "loki/v1/messages" else "loki/v1/moderation/messages"
|
||||||
|
execute(HTTPVerb.DELETE, server, endpoint, parameters = parameters, isJSONRequired = false).then { json ->
|
||||||
|
Log.d("Loki", "Deleted messages with IDs: $messageServerIDs from open group with ID: $channel on server: $server.")
|
||||||
|
messageServerIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun getModerators(channel: Long, server: String): Promise<Set<String>, Exception> {
|
||||||
|
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then(sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST") val moderators = json["moderators"] as? List<String>
|
||||||
|
val moderatorsAsSet = moderators.orEmpty().toSet()
|
||||||
|
if (this.moderators[server] != null) {
|
||||||
|
this.moderators[server]!![channel] = moderatorsAsSet
|
||||||
|
} else {
|
||||||
|
this.moderators[server] = hashMapOf( channel to moderatorsAsSet )
|
||||||
|
}
|
||||||
|
moderatorsAsSet
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse moderators for open group with ID: $channel on server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun getChannelInfo(channel: Long, server: String): Promise<OpenGroupInfo, Exception> {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
val parameters = mapOf( "include_annotations" to 1 )
|
||||||
|
execute(HTTPVerb.GET, server, "/channels/$channel", parameters = parameters).then(sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
val data = json["data"] as Map<*, *>
|
||||||
|
val annotations = data["annotations"] as List<Map<*, *>>
|
||||||
|
val annotation = annotations.find { (it["type"] as? String ?: "") == channelInfoType } ?: throw Error.ParsingFailed
|
||||||
|
val info = annotation["value"] as Map<*, *>
|
||||||
|
val displayName = info["name"] as String
|
||||||
|
val countInfo = data["counts"] as Map<*, *>
|
||||||
|
val memberCount = countInfo["subscribers"] as? Int ?: (countInfo["subscribers"] as? Long)?.toInt() ?: (countInfo["subscribers"] as String).toInt()
|
||||||
|
val profilePictureURL = info["avatar"] as String
|
||||||
|
val publicChatInfo = OpenGroupInfo(displayName, profilePictureURL, memberCount)
|
||||||
|
Configuration.shared.storage.setUserCount(channel, server, memberCount)
|
||||||
|
publicChatInfo
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse info for open group with ID: $channel on server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) {
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
storage.setUserCount(channel, server, info.memberCount)
|
||||||
|
storage.updateTitle(groupID, info.displayName)
|
||||||
|
// Download and update profile picture if needed
|
||||||
|
val oldProfilePictureURL = storage.getOpenGroupProfilePictureURL(channel, server)
|
||||||
|
if (isForcedUpdate || oldProfilePictureURL != info.profilePictureURL) {
|
||||||
|
val profilePictureAsByteArray = downloadOpenGroupProfilePicture(server, info.profilePictureURL) ?: return
|
||||||
|
storage.updateProfilePicture(groupID, profilePictureAsByteArray)
|
||||||
|
storage.setOpenGroupProfilePictureURL(channel, server, info.profilePictureURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? {
|
||||||
|
val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}"
|
||||||
|
Log.d("Loki", "Downloading open group profile picture from \"$url\".")
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
try {
|
||||||
|
DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null)
|
||||||
|
Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"")
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to download open group profile picture from \"$url\" due to error: $e.")
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun join(channel: Long, server: String): Promise<Unit, Exception> {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then {
|
||||||
|
Log.d("Loki", "Joined channel with ID: $channel on server: $server.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun leave(channel: Long, server: String): Promise<Unit, Exception> {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then {
|
||||||
|
Log.d("Loki", "Left channel with ID: $channel on server: $server.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun getDisplayNames(publicKeys: Set<String>, server: String): Promise<Map<String, String>, Exception> {
|
||||||
|
return getUserProfiles(publicKeys, server, false).map(sharedContext) { json ->
|
||||||
|
val mapping = mutableMapOf<String, String>()
|
||||||
|
for (user in json) {
|
||||||
|
if (user["username"] != null) {
|
||||||
|
val publicKey = user["username"] as String
|
||||||
|
val displayName = user["name"] as? String ?: "Anonymous"
|
||||||
|
mapping[publicKey] = displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun setDisplayName(newDisplayName: String?, server: String): Promise<Unit, Exception> {
|
||||||
|
Log.d("Loki", "Updating display name on server: $server.")
|
||||||
|
val parameters = mapOf( "name" to (newDisplayName ?: "") )
|
||||||
|
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit }
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise<Unit, Exception> {
|
||||||
|
return setProfilePicture(server, Base64.encodeBytes(profileKey), url)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun setProfilePicture(server: String, profileKey: String, url: String?): Promise<Unit, Exception> {
|
||||||
|
Log.d("Loki", "Updating profile picture on server: $server.")
|
||||||
|
val value = when (url) {
|
||||||
|
null -> null
|
||||||
|
else -> mapOf( "profileKey" to profileKey, "url" to url )
|
||||||
|
}
|
||||||
|
// TODO: This may actually completely replace the annotations, have to double check it
|
||||||
|
return setSelfAnnotation(server, profilePictureType, value).map { Unit }.fail {
|
||||||
|
Log.d("Loki", "Failed to update profile picture due to error: $it.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package org.session.libsession.messaging.opengroups
|
||||||
|
|
||||||
|
public data class OpenGroupInfo (
|
||||||
|
public val displayName: String,
|
||||||
|
public val profilePictureURL: String,
|
||||||
|
public val memberCount: Int
|
||||||
|
)
|
@ -0,0 +1,242 @@
|
|||||||
|
package org.session.libsession.messaging.opengroups
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.util.Hex
|
||||||
|
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
|
||||||
|
import org.whispersystems.curve25519.Curve25519
|
||||||
|
|
||||||
|
public data class OpenGroupMessage(
|
||||||
|
public val serverID: Long?,
|
||||||
|
public val senderPublicKey: String,
|
||||||
|
public val displayName: String,
|
||||||
|
public val body: String,
|
||||||
|
public val timestamp: Long,
|
||||||
|
public val type: String,
|
||||||
|
public val quote: Quote?,
|
||||||
|
public val attachments: List<Attachment>,
|
||||||
|
public val profilePicture: ProfilePicture?,
|
||||||
|
public val signature: Signature?,
|
||||||
|
public val serverTimestamp: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// region Settings
|
||||||
|
companion object {
|
||||||
|
fun from(message: VisibleMessage, server: String): OpenGroupMessage? {
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val userPublicKey = storage.getUserPublicKey() ?: return null
|
||||||
|
// Validation
|
||||||
|
if (!message.isValid()) { return null } // Should be valid at this point
|
||||||
|
// Quote
|
||||||
|
val quote: Quote? = {
|
||||||
|
val quote = message.quote
|
||||||
|
if (quote != null && quote.isValid()) {
|
||||||
|
val quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey)
|
||||||
|
Quote(quote.timestamp!!, quote.publicKey!!, quote.text!!, quotedMessageServerID)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Message
|
||||||
|
val displayname = storage.getUserDisplayName() ?: "Anonymous"
|
||||||
|
val body = message.text ?: message.sentTimestamp.toString() // The back-end doesn't accept messages without a body so we use this as a workaround
|
||||||
|
val result = OpenGroupMessage(null, userPublicKey, displayname, body, message.sentTimestamp!!, OpenGroupAPI.openGroupMessageType, quote, mutableListOf(), null, null, 0)
|
||||||
|
// Link preview
|
||||||
|
val linkPreview = message.linkPreview
|
||||||
|
linkPreview?.let {
|
||||||
|
if (!linkPreview.isValid()) { return@let }
|
||||||
|
val attachment = linkPreview.getImage() ?: return@let
|
||||||
|
val openGroupLinkPreview = Attachment(
|
||||||
|
Attachment.Kind.LinkPreview,
|
||||||
|
server,
|
||||||
|
attachment.getId(),
|
||||||
|
attachment.getContentType(),
|
||||||
|
attachment.getSize(),
|
||||||
|
attachment.getFileName(),
|
||||||
|
attachment.getFlags(),
|
||||||
|
attachment.getWidth(),
|
||||||
|
attachment.getHeight(),
|
||||||
|
attachment.getCaption(),
|
||||||
|
attachment.getUrl(),
|
||||||
|
linkPreview.url,
|
||||||
|
linkPreview.title)
|
||||||
|
result.attachments.add(openGroupLinkPreview)
|
||||||
|
}
|
||||||
|
// Attachments
|
||||||
|
val attachments = message.getAttachemnts().forEach {
|
||||||
|
val attachement = Attachment(
|
||||||
|
Attachment.Kind.Attachment,
|
||||||
|
server,
|
||||||
|
it.getId(),
|
||||||
|
it.getContentType(),
|
||||||
|
it.getSize(),
|
||||||
|
it.getFileName(),
|
||||||
|
it.getFlags(),
|
||||||
|
it.getWidth(),
|
||||||
|
it.getHeight(),
|
||||||
|
it.getCaption(),
|
||||||
|
it.getUrl(),
|
||||||
|
linkPreview.getUrl(),
|
||||||
|
linkPreview.getTitle())
|
||||||
|
result.attachments.add(attachement)
|
||||||
|
}
|
||||||
|
// Return
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private val curve = Curve25519.getInstance(Curve25519.BEST)
|
||||||
|
private val signatureVersion: Long = 1
|
||||||
|
private val attachmentType = "net.app.core.oembed"
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Types
|
||||||
|
public data class ProfilePicture(
|
||||||
|
public val profileKey: ByteArray,
|
||||||
|
public val url: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
public data class Quote(
|
||||||
|
public val quotedMessageTimestamp: Long,
|
||||||
|
public val quoteePublicKey: String,
|
||||||
|
public val quotedMessageBody: String,
|
||||||
|
public val quotedMessageServerID: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
public data class Signature(
|
||||||
|
public val data: ByteArray,
|
||||||
|
public val version: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
public data class Attachment(
|
||||||
|
public val kind: Kind,
|
||||||
|
public val server: String,
|
||||||
|
public val serverID: Long,
|
||||||
|
public val contentType: String,
|
||||||
|
public val size: Int,
|
||||||
|
public val fileName: String,
|
||||||
|
public val flags: Int,
|
||||||
|
public val width: Int,
|
||||||
|
public val height: Int,
|
||||||
|
public val caption: String?,
|
||||||
|
public val url: String,
|
||||||
|
/**
|
||||||
|
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
|
||||||
|
*/
|
||||||
|
public val linkPreviewURL: String?,
|
||||||
|
/**
|
||||||
|
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
|
||||||
|
*/
|
||||||
|
public val linkPreviewTitle: String?,
|
||||||
|
) {
|
||||||
|
public val dotNetAPIType = when {
|
||||||
|
contentType.startsWith("image") -> "photo"
|
||||||
|
contentType.startsWith("video") -> "video"
|
||||||
|
contentType.startsWith("audio") -> "audio"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum class Kind(val rawValue: String) {
|
||||||
|
Attachment("attachment"), LinkPreview("preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Initialization
|
||||||
|
constructor(hexEncodedPublicKey: String, displayName: String, body: String, timestamp: Long, type: String, quote: Quote?, attachments: List<Attachment>)
|
||||||
|
: this(null, hexEncodedPublicKey, displayName, body, timestamp, type, quote, attachments, null, null, 0)
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Crypto
|
||||||
|
internal fun sign(privateKey: ByteArray): OpenGroupMessage? {
|
||||||
|
val data = getValidationData(signatureVersion)
|
||||||
|
if (data == null) {
|
||||||
|
Log.d("Loki", "Failed to sign public chat message.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val signatureData = curve.calculateSignature(privateKey, data)
|
||||||
|
val signature = Signature(signatureData, signatureVersion)
|
||||||
|
return copy(signature = signature)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to sign public chat message due to error: ${e.message}.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasValidSignature(): Boolean {
|
||||||
|
if (signature == null) { return false }
|
||||||
|
val data = getValidationData(signature.version) ?: return false
|
||||||
|
val publicKey = Hex.fromStringCondensed(senderPublicKey.removing05PrefixIfNeeded())
|
||||||
|
try {
|
||||||
|
return curve.verifySignature(publicKey, data, signature.data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to verify public chat message due to error: ${e.message}.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Parsing
|
||||||
|
internal fun toJSON(): Map<String, Any> {
|
||||||
|
val value = mutableMapOf<String, Any>("timestamp" to timestamp)
|
||||||
|
if (quote != null) {
|
||||||
|
value["quote"] = mapOf("id" to quote.quotedMessageTimestamp, "author" to quote.quoteePublicKey, "text" to quote.quotedMessageBody)
|
||||||
|
}
|
||||||
|
if (signature != null) {
|
||||||
|
value["sig"] = Hex.toStringCondensed(signature.data)
|
||||||
|
value["sigver"] = signature.version
|
||||||
|
}
|
||||||
|
val annotation = mapOf("type" to type, "value" to value)
|
||||||
|
val annotations = mutableListOf(annotation)
|
||||||
|
attachments.forEach { attachment ->
|
||||||
|
val attachmentValue = mutableMapOf(
|
||||||
|
// Fields required by the .NET API
|
||||||
|
"version" to 1,
|
||||||
|
"type" to attachment.dotNetAPIType,
|
||||||
|
// Custom fields
|
||||||
|
"lokiType" to attachment.kind.rawValue,
|
||||||
|
"server" to attachment.server,
|
||||||
|
"id" to attachment.serverID,
|
||||||
|
"contentType" to attachment.contentType,
|
||||||
|
"size" to attachment.size,
|
||||||
|
"fileName" to attachment.fileName,
|
||||||
|
"flags" to attachment.flags,
|
||||||
|
"width" to attachment.width,
|
||||||
|
"height" to attachment.height,
|
||||||
|
"url" to attachment.url
|
||||||
|
)
|
||||||
|
if (attachment.caption != null) { attachmentValue["caption"] = attachment.caption }
|
||||||
|
if (attachment.linkPreviewURL != null) { attachmentValue["linkPreviewUrl"] = attachment.linkPreviewURL }
|
||||||
|
if (attachment.linkPreviewTitle != null) { attachmentValue["linkPreviewTitle"] = attachment.linkPreviewTitle }
|
||||||
|
val attachmentAnnotation = mapOf("type" to attachmentType, "value" to attachmentValue)
|
||||||
|
annotations.add(attachmentAnnotation)
|
||||||
|
}
|
||||||
|
val result = mutableMapOf("text" to body, "annotations" to annotations)
|
||||||
|
if (quote?.quotedMessageServerID != null) {
|
||||||
|
result["reply_to"] = quote.quotedMessageServerID
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Convenience
|
||||||
|
private fun getValidationData(signatureVersion: Long): ByteArray? {
|
||||||
|
var string = "${body.trim()}$timestamp"
|
||||||
|
if (quote != null) {
|
||||||
|
string += "${quote.quotedMessageTimestamp}${quote.quoteePublicKey}${quote.quotedMessageBody.trim()}"
|
||||||
|
if (quote.quotedMessageServerID != null) {
|
||||||
|
string += "${quote.quotedMessageServerID}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string += attachments.sortedBy { it.serverID }.map { it.serverID }.joinToString("")
|
||||||
|
string += "$signatureVersion"
|
||||||
|
try {
|
||||||
|
return string.toByteArray(Charsets.UTF_8)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
@ -1,4 +1,29 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
object MessageReceiver {
|
object MessageReceiver {
|
||||||
|
internal sealed class Error(val description: String) : Exception() {
|
||||||
|
object InvalidMessage: Error("Invalid message.")
|
||||||
|
object UnknownMessage: Error("Unknown message type.")
|
||||||
|
object UnknownEnvelopeType: Error("Unknown envelope type.")
|
||||||
|
object NoUserPublicKey: Error("Couldn't find user key pair.")
|
||||||
|
object NoData: Error("Received an empty envelope.")
|
||||||
|
object SenderBlocked: Error("Received a message from a blocked user.")
|
||||||
|
object NoThread: Error("Couldn't find thread for message.")
|
||||||
|
object SelfSend: Error("Message addressed at self.")
|
||||||
|
object ParsingFailed : Error("Couldn't parse ciphertext message.")
|
||||||
|
// Shared sender keys
|
||||||
|
object InvalidGroupPublicKey: Error("Invalid group public key.")
|
||||||
|
object NoGroupPrivateKey: Error("Missing group private key.")
|
||||||
|
object SharedSecretGenerationFailed: Error("Couldn't generate a shared secret.")
|
||||||
|
|
||||||
|
internal val isRetryable: Boolean = when (this) {
|
||||||
|
is InvalidMessage -> false
|
||||||
|
is UnknownMessage -> false
|
||||||
|
is UnknownEnvelopeType -> false
|
||||||
|
is NoData -> false
|
||||||
|
is SenderBlocked -> false
|
||||||
|
is SelfSend -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,58 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsignal.service.api.push.SignalServiceAddress
|
||||||
|
import org.session.libsignal.service.loki.crypto.LokiServiceCipher
|
||||||
|
import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error
|
||||||
|
import org.session.libsession.utilities.AESGCM
|
||||||
|
import org.whispersystems.curve25519.Curve25519
|
||||||
|
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
|
||||||
|
import org.session.libsignal.libsignal.util.Pair
|
||||||
|
import org.session.libsignal.service.api.messages.SignalServiceEnvelope
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
||||||
|
import org.session.libsignal.service.loki.utilities.toHexString
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
object MessageReceiverDecryption {
|
object MessageReceiverDecryption {
|
||||||
|
|
||||||
|
internal fun decryptWithSignalProtocol(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
|
||||||
|
val storage = Configuration.shared.signalStorage
|
||||||
|
val certificateValidator = Configuration.shared.certificateValidator
|
||||||
|
val sskDatabase = Configuration.shared.sskDatabase
|
||||||
|
val sessionResetImp = Configuration.shared.sessionResetImp
|
||||||
|
val data = envelope.content
|
||||||
|
if (data.count() == 0) { throw Error.NoData }
|
||||||
|
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
|
||||||
|
val cipher = LokiServiceCipher(SignalServiceAddress(userPublicKey), storage, sskDatabase, sessionResetImp, certificateValidator)
|
||||||
|
val result = cipher.decrypt(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decryptWithSharedSenderKeys(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
|
||||||
|
// 1. ) Check preconditions
|
||||||
|
val groupPublicKey = envelope.source
|
||||||
|
if (!Configuration.shared.storage.isClosedGroup(groupPublicKey)) { throw Error.InvalidGroupPublicKey }
|
||||||
|
val data = envelope.content
|
||||||
|
if (data.count() == 0) { throw Error.NoData }
|
||||||
|
val groupPrivateKey = Configuration.shared.storage.getClosedGroupPrivateKey(groupPublicKey) ?: throw Error.NoGroupPrivateKey
|
||||||
|
// 2. ) Parse the wrapper
|
||||||
|
val wrapper = SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.parseFrom(data)
|
||||||
|
val ivAndCiphertext = wrapper.ciphertext.toByteArray()
|
||||||
|
val ephemeralPublicKey = wrapper.ephemeralPublicKey.toByteArray()
|
||||||
|
// 3. ) Decrypt the data inside
|
||||||
|
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(ephemeralPublicKey, groupPrivateKey.serialize())
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
|
||||||
|
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
|
||||||
|
val closedGroupCiphertextMessageAsData = AESGCM.decrypt(ivAndCiphertext, symmetricKey)
|
||||||
|
// 4. ) Parse the closed group ciphertext message
|
||||||
|
val closedGroupCiphertextMessage = ClosedGroupCiphertextMessage.from(closedGroupCiphertextMessageAsData) ?: throw Error.ParsingFailed
|
||||||
|
val senderPublicKey = closedGroupCiphertextMessage.senderPublicKey.toHexString()
|
||||||
|
if (senderPublicKey == Configuration.shared.storage.getUserPublicKey()) { throw Error.SelfSend }
|
||||||
|
// 5. ) Use the info inside the closed group ciphertext message to decrypt the actual message content
|
||||||
|
val plaintext = SharedSenderKeysImplementation.shared.decrypt(closedGroupCiphertextMessage.ivAndCiphertext, groupPublicKey, senderPublicKey, closedGroupCiphertextMessage.keyIndex)
|
||||||
|
// 6. ) Return
|
||||||
|
return Pair(plaintext, senderPublicKey)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
interface MessageReceiverDelegate {
|
interface MessageReceiverDelegate {
|
||||||
}
|
}
|
@ -1,4 +1,198 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.deferred
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
|
import org.session.libsession.messaging.messages.Destination
|
||||||
|
import org.session.libsession.messaging.messages.Message
|
||||||
|
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||||
|
import org.session.libsession.messaging.jobs.NotifyPNServerJob
|
||||||
|
import org.session.libsession.messaging.opengroups.OpenGroupAPI
|
||||||
|
import org.session.libsession.messaging.opengroups.OpenGroupMessage
|
||||||
|
import org.session.libsession.messaging.utilities.MessageWrapper
|
||||||
|
import org.session.libsession.snode.RawResponsePromise
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.snode.SnodeMessage
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
|
||||||
|
|
||||||
|
|
||||||
object MessageSender {
|
object MessageSender {
|
||||||
|
|
||||||
|
// Error
|
||||||
|
internal sealed class Error(val description: String) : Exception() {
|
||||||
|
object InvalidMessage : Error("Invalid message.")
|
||||||
|
object ProtoConversionFailed : Error("Couldn't convert message to proto.")
|
||||||
|
object ProofOfWorkCalculationFailed : Error("Proof of work calculation failed.")
|
||||||
|
object NoUserPublicKey : Error("Couldn't find user key pair.")
|
||||||
|
|
||||||
|
// Closed groups
|
||||||
|
object NoThread : Error("Couldn't find a thread associated with the given group public key.")
|
||||||
|
object NoPrivateKey : Error("Couldn't find a private key associated with the given group public key.")
|
||||||
|
object InvalidClosedGroupUpdate : Error("Invalid group update.")
|
||||||
|
|
||||||
|
internal val isRetryable: Boolean = when (this) {
|
||||||
|
is InvalidMessage -> false
|
||||||
|
is ProtoConversionFailed -> false
|
||||||
|
is ProofOfWorkCalculationFailed -> false
|
||||||
|
is InvalidClosedGroupUpdate -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience
|
||||||
|
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
|
||||||
|
if (destination is Destination.OpenGroup) {
|
||||||
|
return sendToOpenGroupDestination(destination, message)
|
||||||
|
}
|
||||||
|
return sendToSnodeDestination(destination, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-on-One Chats & Closed Groups
|
||||||
|
fun sendToSnodeDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
|
||||||
|
val deferred = deferred<Unit, Exception>()
|
||||||
|
val promise = deferred.promise
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val preconditionFailure = Exception("Destination should not be open groups!")
|
||||||
|
var snodeMessage: SnodeMessage? = null
|
||||||
|
message.sentTimestamp ?: run { message.sentTimestamp = System.currentTimeMillis() } /* Visible messages will already have their sent timestamp set */
|
||||||
|
message.sender = storage.getUserPublicKey()
|
||||||
|
try {
|
||||||
|
when (destination) {
|
||||||
|
is Destination.Contact -> message.recipient = destination.publicKey
|
||||||
|
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
|
||||||
|
is Destination.OpenGroup -> throw preconditionFailure
|
||||||
|
}
|
||||||
|
// Validate the message
|
||||||
|
if (!message.isValid()) { throw Error.InvalidMessage }
|
||||||
|
// Convert it to protobuf
|
||||||
|
val proto = message.toProto() ?: throw Error.ProtoConversionFailed
|
||||||
|
// Serialize the protobuf
|
||||||
|
val plaintext = proto.toByteArray()
|
||||||
|
// Encrypt the serialized protobuf
|
||||||
|
val ciphertext: ByteArray
|
||||||
|
when (destination) {
|
||||||
|
is Destination.Contact -> ciphertext = MessageSenderEncryption.encryptWithSignalProtocol(plaintext, message, destination.publicKey)
|
||||||
|
is Destination.ClosedGroup -> ciphertext = MessageSenderEncryption.encryptWithSharedSenderKeys(plaintext, destination.groupPublicKey)
|
||||||
|
is Destination.OpenGroup -> throw preconditionFailure
|
||||||
|
}
|
||||||
|
// Wrap the result
|
||||||
|
val kind: SignalServiceProtos.Envelope.Type
|
||||||
|
val senderPublicKey: String
|
||||||
|
when (destination) {
|
||||||
|
is Destination.Contact -> {
|
||||||
|
kind = SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER
|
||||||
|
senderPublicKey = ""
|
||||||
|
}
|
||||||
|
is Destination.ClosedGroup -> {
|
||||||
|
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT
|
||||||
|
senderPublicKey = destination.groupPublicKey
|
||||||
|
}
|
||||||
|
is Destination.OpenGroup -> throw preconditionFailure
|
||||||
|
}
|
||||||
|
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
|
||||||
|
// Calculate proof of work
|
||||||
|
val recipient = message.recipient!!
|
||||||
|
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val nonce = ProofOfWork.calculate(base64EncodedData, recipient, timestamp, message.ttl.toInt()) ?: throw Error.ProofOfWorkCalculationFailed
|
||||||
|
// Send the result
|
||||||
|
snodeMessage = SnodeMessage(recipient, base64EncodedData, message.ttl, timestamp, nonce)
|
||||||
|
SnodeAPI.sendMessage(snodeMessage).success { promises: Set<RawResponsePromise> ->
|
||||||
|
var isSuccess = false
|
||||||
|
val promiseCount = promises.size
|
||||||
|
var errorCount = 0
|
||||||
|
promises.forEach { promise: RawResponsePromise ->
|
||||||
|
promise.success {
|
||||||
|
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
|
||||||
|
isSuccess = true
|
||||||
|
deferred.resolve(Unit)
|
||||||
|
}
|
||||||
|
promise.fail {
|
||||||
|
errorCount += 1
|
||||||
|
if (errorCount != promiseCount) { return@fail } // Only error out if all promises failed
|
||||||
|
deferred.reject(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.fail {
|
||||||
|
Log.d("Loki", "Couldn't send message due to error: $it.")
|
||||||
|
deferred.reject(it)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
// Handle completion
|
||||||
|
promise.success {
|
||||||
|
handleSuccessfulMessageSend(message)
|
||||||
|
if (message is VisibleMessage && snodeMessage != null) {
|
||||||
|
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
|
||||||
|
JobQueue.shared.add(notifyPNServerJob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.fail {
|
||||||
|
handleFailedMessageSend(message, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Groups
|
||||||
|
fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
|
||||||
|
val deferred = deferred<Unit, Exception>()
|
||||||
|
val promise = deferred.promise
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val preconditionFailure = Exception("Destination should not be contacts or closed groups!")
|
||||||
|
message.sentTimestamp = System.currentTimeMillis()
|
||||||
|
message.sender = storage.getUserPublicKey()
|
||||||
|
try {
|
||||||
|
val server: String
|
||||||
|
val channel: Long
|
||||||
|
when (destination) {
|
||||||
|
is Destination.Contact -> throw preconditionFailure
|
||||||
|
is Destination.ClosedGroup -> throw preconditionFailure
|
||||||
|
is Destination.OpenGroup -> {
|
||||||
|
message.recipient = "${destination.server}.${destination.channel}"
|
||||||
|
server = destination.server
|
||||||
|
channel = destination.channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate the message
|
||||||
|
if (message !is VisibleMessage || !message.isValid()) {
|
||||||
|
throw Error.InvalidMessage
|
||||||
|
}
|
||||||
|
// Convert the message to an open group message
|
||||||
|
val openGroupMessage = OpenGroupMessage.from(message, server) ?: throw Error.InvalidMessage
|
||||||
|
// Send the result
|
||||||
|
OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success {
|
||||||
|
message.openGroupServerMessageID = it.serverID
|
||||||
|
deferred.resolve(Unit)
|
||||||
|
}.fail {
|
||||||
|
deferred.reject(it)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
// Handle completion
|
||||||
|
promise.success {
|
||||||
|
handleSuccessfulMessageSend(message)
|
||||||
|
}
|
||||||
|
promise.fail {
|
||||||
|
handleFailedMessageSend(message, it)
|
||||||
|
}
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result Handling
|
||||||
|
fun handleSuccessfulMessageSend(message: Message) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleFailedMessageSend(message: Message, error: Exception) {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
interface MessageSenderDelegate {
|
interface MessageSenderDelegate {
|
||||||
}
|
}
|
@ -1,4 +1,52 @@
|
|||||||
package org.session.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsession.messaging.messages.Message
|
||||||
|
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
|
||||||
|
import org.session.libsession.messaging.utilities.UnidentifiedAccessUtil
|
||||||
|
import org.session.libsession.utilities.AESGCM
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.SignalProtocolAddress
|
||||||
|
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
|
||||||
|
import org.session.libsignal.libsignal.util.Hex
|
||||||
|
import org.session.libsignal.libsignal.util.guava.Optional
|
||||||
|
import org.session.libsignal.service.api.crypto.SignalServiceCipher
|
||||||
|
import org.session.libsignal.service.api.push.SignalServiceAddress
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
||||||
|
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
|
||||||
|
|
||||||
object MessageSenderEncryption {
|
object MessageSenderEncryption {
|
||||||
|
|
||||||
|
internal fun encryptWithSignalProtocol(plaintext: ByteArray, message: Message, recipientPublicKey: String): ByteArray{
|
||||||
|
val storage = Configuration.shared.signalStorage
|
||||||
|
val sskDatabase = Configuration.shared.sskDatabase
|
||||||
|
val sessionResetImp = Configuration.shared.sessionResetImp
|
||||||
|
val localAddress = SignalServiceAddress(recipientPublicKey)
|
||||||
|
val certificateValidator = Configuration.shared.certificateValidator
|
||||||
|
val cipher = SignalServiceCipher(localAddress, storage, sskDatabase, sessionResetImp, certificateValidator)
|
||||||
|
val signalProtocolAddress = SignalProtocolAddress(recipientPublicKey, 1)
|
||||||
|
val unidentifiedAccessPair = UnidentifiedAccessUtil.getAccessFor(recipientPublicKey)
|
||||||
|
val unidentifiedAccess = if (unidentifiedAccessPair != null) unidentifiedAccessPair.targetUnidentifiedAccess else Optional.absent()
|
||||||
|
val encryptedMessage = cipher.encrypt(signalProtocolAddress, unidentifiedAccess, plaintext)
|
||||||
|
return Base64.decode(encryptedMessage.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun encryptWithSharedSenderKeys(plaintext: ByteArray, groupPublicKey: String): ByteArray {
|
||||||
|
// 1. ) Encrypt the data with the user's sender key
|
||||||
|
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
|
||||||
|
val ciphertextAndKeyIndex = SharedSenderKeysImplementation.shared.encrypt(plaintext, groupPublicKey, userPublicKey)
|
||||||
|
val ivAndCiphertext = ciphertextAndKeyIndex.first
|
||||||
|
val keyIndex = ciphertextAndKeyIndex.second
|
||||||
|
val encryptedMessage = ClosedGroupCiphertextMessage(ivAndCiphertext, Hex.fromStringCondensed(userPublicKey), keyIndex);
|
||||||
|
// 2. ) Encrypt the result for the group's public key to hide the sender public key and key index
|
||||||
|
val intermediate = AESGCM.encrypt(encryptedMessage.serialize(), groupPublicKey.removing05PrefixIfNeeded())
|
||||||
|
// 3. ) Wrap the result
|
||||||
|
return SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.newBuilder()
|
||||||
|
.setCiphertext(ByteString.copyFrom(intermediate.ciphertext))
|
||||||
|
.setEphemeralPublicKey(ByteString.copyFrom(intermediate.ephemeralPublicKey))
|
||||||
|
.build().toByteArray()
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,2 +0,0 @@
|
|||||||
package org.session.messaging.sending_receiving
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
|||||||
|
package org.session.libsession.messaging.sending_receiving.notifications
|
||||||
|
|
@ -0,0 +1,101 @@
|
|||||||
|
package org.session.libsession.messaging.sending_receiving.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import okhttp3.*
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||||
|
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object PushNotificationAPI {
|
||||||
|
val server = "https://live.apns.getsession.org"
|
||||||
|
val serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
|
||||||
|
private val maxRetryCount = 4
|
||||||
|
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
enum class ClosedGroupOperation {
|
||||||
|
Subscribe, Unsubscribe;
|
||||||
|
|
||||||
|
val rawValue: String
|
||||||
|
get() {
|
||||||
|
return when (this) {
|
||||||
|
Subscribe -> "subscribe_closed_group"
|
||||||
|
Unsubscribe -> "unsubscribe_closed_group"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(token: String, context: Context) {
|
||||||
|
val parameters = mapOf( "token" to token )
|
||||||
|
val url = "$server/unregister"
|
||||||
|
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||||
|
val request = Request.Builder().url(url).post(body)
|
||||||
|
retryIfNeeded(maxRetryCount) {
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||||
|
val code = json["code"] as? Int
|
||||||
|
if (code != null && code != 0) {
|
||||||
|
TextSecurePreferences.setIsUsingFCM(context, false)
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||||
|
}
|
||||||
|
}.fail { exception ->
|
||||||
|
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unsubscribe from all closed groups
|
||||||
|
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
|
||||||
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||||
|
allClosedGroupPublicKeys.forEach { closedGroup ->
|
||||||
|
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(token: String, publicKey: String, context: Context, force: Boolean) {
|
||||||
|
val oldToken = TextSecurePreferences.getFCMToken(context)
|
||||||
|
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
|
||||||
|
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
|
||||||
|
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
|
||||||
|
val url = "$server/register"
|
||||||
|
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||||
|
val request = Request.Builder().url(url).post(body)
|
||||||
|
retryIfNeeded(maxRetryCount) {
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||||
|
val code = json["code"] as? Int
|
||||||
|
if (code != null && code != 0) {
|
||||||
|
TextSecurePreferences.setIsUsingFCM(context, true)
|
||||||
|
TextSecurePreferences.setFCMToken(context, token)
|
||||||
|
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||||
|
}
|
||||||
|
}.fail { exception ->
|
||||||
|
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Subscribe to all closed groups
|
||||||
|
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
|
||||||
|
allClosedGroupPublicKeys.forEach { closedGroup ->
|
||||||
|
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
|
||||||
|
if (!TextSecurePreferences.isUsingFCM(context)) { return }
|
||||||
|
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
|
||||||
|
val url = "$server/${operation.rawValue}"
|
||||||
|
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||||
|
val request = Request.Builder().url(url).post(body)
|
||||||
|
retryIfNeeded(maxRetryCount) {
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||||
|
val code = json["code"] as? Int
|
||||||
|
if (code == null || code == 0) {
|
||||||
|
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.")
|
||||||
|
}
|
||||||
|
}.fail { exception ->
|
||||||
|
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,268 @@
|
|||||||
|
package org.session.libsession.messaging.utilities
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import nl.komponents.kovenant.then
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.messaging.fileserver.FileServerAPI
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.libsignal.loki.DiffieHellman
|
||||||
|
import org.session.libsignal.service.api.crypto.ProfileCipherOutputStream
|
||||||
|
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||||
|
import org.session.libsignal.service.api.push.exceptions.PushNetworkException
|
||||||
|
import org.session.libsignal.service.api.util.StreamDetails
|
||||||
|
import org.session.libsignal.service.internal.push.ProfileAvatarData
|
||||||
|
import org.session.libsignal.service.internal.push.PushAttachmentData
|
||||||
|
import org.session.libsignal.service.internal.push.http.DigestingRequestBody
|
||||||
|
import org.session.libsignal.service.internal.push.http.ProfileCipherOutputStreamFactory
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.internal.util.Hex
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||||
|
import org.session.libsignal.service.loki.utilities.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class that provides utilities for .NET based APIs.
|
||||||
|
*/
|
||||||
|
open class DotNetAPI {
|
||||||
|
|
||||||
|
internal enum class HTTPVerb { GET, PUT, POST, DELETE, PATCH }
|
||||||
|
|
||||||
|
// Error
|
||||||
|
internal sealed class Error(val description: String) : Exception() {
|
||||||
|
object Generic : Error("An error occurred.")
|
||||||
|
object InvalidURL : Error("Invalid URL.")
|
||||||
|
object ParsingFailed : Error("Invalid file server response.")
|
||||||
|
object SigningFailed : Error("Couldn't sign message.")
|
||||||
|
object EncryptionFailed : Error("Couldn't encrypt file.")
|
||||||
|
object DecryptionFailed : Error("Couldn't decrypt file.")
|
||||||
|
object MaxFileSizeExceeded : Error("Maximum file size exceeded.")
|
||||||
|
object TokenExpired: Error("Token expired.") // Session Android
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val authTokenRequestCache = hashMapOf<String, Promise<String, Exception>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)
|
||||||
|
|
||||||
|
public fun getAuthToken(server: String): Promise<String, Exception> {
|
||||||
|
val storage = Configuration.shared.storage
|
||||||
|
val token = storage.getAuthToken(server)
|
||||||
|
if (token != null) { return Promise.of(token) }
|
||||||
|
// Avoid multiple token requests to the server by caching
|
||||||
|
var promise = authTokenRequestCache[server]
|
||||||
|
if (promise == null) {
|
||||||
|
promise = requestNewAuthToken(server).bind { submitAuthToken(it, server) }.then { newToken ->
|
||||||
|
storage.setAuthToken(server, newToken)
|
||||||
|
newToken
|
||||||
|
}.always {
|
||||||
|
authTokenRequestCache.remove(server)
|
||||||
|
}
|
||||||
|
authTokenRequestCache[server] = promise
|
||||||
|
}
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNewAuthToken(server: String): Promise<String, Exception> {
|
||||||
|
Log.d("Loki", "Requesting auth token for server: $server.")
|
||||||
|
val userKeyPair = Configuration.shared.storage.getUserKeyPair() ?: throw Error.Generic
|
||||||
|
val parameters: Map<String, Any> = mapOf( "pubKey" to userKeyPair.hexEncodedPublicKey )
|
||||||
|
return execute(HTTPVerb.GET, server, "loki/v1/get_challenge", false, parameters).map(SnodeAPI.sharedContext) { json ->
|
||||||
|
try {
|
||||||
|
val base64EncodedChallenge = json["cipherText64"] as String
|
||||||
|
val challenge = Base64.decode(base64EncodedChallenge)
|
||||||
|
val base64EncodedServerPublicKey = json["serverPubKey64"] as String
|
||||||
|
var serverPublicKey = Base64.decode(base64EncodedServerPublicKey)
|
||||||
|
// Discard the "05" prefix if needed
|
||||||
|
if (serverPublicKey.count() == 33) {
|
||||||
|
val hexEncodedServerPublicKey = Hex.toStringCondensed(serverPublicKey)
|
||||||
|
serverPublicKey = Hex.fromStringCondensed(hexEncodedServerPublicKey.removing05PrefixIfNeeded())
|
||||||
|
}
|
||||||
|
// The challenge is prefixed by the 16 bit IV
|
||||||
|
val tokenAsData = DiffieHellman.decrypt(challenge, serverPublicKey, userKeyPair.privateKey.serialize())
|
||||||
|
val token = tokenAsData.toString(Charsets.UTF_8)
|
||||||
|
token
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Couldn't parse auth token for server: $server.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitAuthToken(token: String, server: String): Promise<String, Exception> {
|
||||||
|
Log.d("Loki", "Submitting auth token for server: $server.")
|
||||||
|
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.Generic
|
||||||
|
val parameters = mapOf( "pubKey" to userPublicKey, "token" to token )
|
||||||
|
return execute(HTTPVerb.POST, server, "loki/v1/submit_challenge", false, parameters, isJSONRequired = false).map { token }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun execute(verb: HTTPVerb, server: String, endpoint: String, isAuthRequired: Boolean = true, parameters: Map<String, Any> = mapOf(), isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||||
|
fun execute(token: String?): Promise<Map<*, *>, Exception> {
|
||||||
|
val sanitizedEndpoint = endpoint.removePrefix("/")
|
||||||
|
var url = "$server/$sanitizedEndpoint"
|
||||||
|
if (verb == HTTPVerb.GET || verb == HTTPVerb.DELETE) {
|
||||||
|
val queryParameters = parameters.map { "${it.key}=${it.value}" }.joinToString("&")
|
||||||
|
if (queryParameters.isNotEmpty()) { url += "?$queryParameters" }
|
||||||
|
}
|
||||||
|
var request = Request.Builder().url(url)
|
||||||
|
if (isAuthRequired) {
|
||||||
|
if (token == null) { throw IllegalStateException() }
|
||||||
|
request = request.header("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
when (verb) {
|
||||||
|
HTTPVerb.GET -> request = request.get()
|
||||||
|
HTTPVerb.DELETE -> request = request.delete()
|
||||||
|
else -> {
|
||||||
|
val parametersAsJSON = JsonUtil.toJson(parameters)
|
||||||
|
val body = RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
|
||||||
|
when (verb) {
|
||||||
|
HTTPVerb.PUT -> request = request.put(body)
|
||||||
|
HTTPVerb.POST -> request = request.post(body)
|
||||||
|
HTTPVerb.PATCH -> request = request.patch(body)
|
||||||
|
else -> throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val serverPublicKeyPromise = if (server == FileServerAPI.shared.server) Promise.of(FileServerAPI.fileServerPublicKey)
|
||||||
|
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(server)
|
||||||
|
return serverPublicKeyPromise.bind { serverPublicKey ->
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, isJSONRequired = isJSONRequired).recover { exception ->
|
||||||
|
if (exception is HTTP.HTTPRequestFailedException) {
|
||||||
|
val statusCode = exception.statusCode
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
|
Configuration.shared.storage.setAuthToken(server, null)
|
||||||
|
throw Error.TokenExpired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (isAuthRequired) {
|
||||||
|
getAuthToken(server).bind { execute(it) }
|
||||||
|
} else {
|
||||||
|
execute(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getUserProfiles(publicKeys: Set<String>, server: String, includeAnnotations: Boolean): Promise<List<Map<*, *>>, Exception> {
|
||||||
|
val parameters = mapOf( "include_user_annotations" to includeAnnotations.toInt(), "ids" to publicKeys.joinToString { "@$it" } )
|
||||||
|
return execute(HTTPVerb.GET, server, "users", parameters = parameters).map { json ->
|
||||||
|
val data = json["data"] as? List<Map<*, *>>
|
||||||
|
if (data == null) {
|
||||||
|
Log.d("Loki", "Couldn't parse user profiles for: $publicKeys from: $json.")
|
||||||
|
throw Error.ParsingFailed
|
||||||
|
}
|
||||||
|
data!! // For some reason the compiler can't infer that this can't be null at this point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun setSelfAnnotation(server: String, type: String, newValue: Any?): Promise<Map<*, *>, Exception> {
|
||||||
|
val annotation = mutableMapOf<String, Any>( "type" to type )
|
||||||
|
if (newValue != null) { annotation["value"] = newValue }
|
||||||
|
val parameters = mapOf( "annotations" to listOf( annotation ) )
|
||||||
|
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||||
|
fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult {
|
||||||
|
// This function mimics what Signal does in PushServiceSocket
|
||||||
|
val contentType = "application/octet-stream"
|
||||||
|
val file = DigestingRequestBody(attachment.data, attachment.outputStreamFactory, contentType, attachment.dataSize, attachment.listener)
|
||||||
|
Log.d("Loki", "File size: ${attachment.dataSize} bytes.")
|
||||||
|
val body = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("type", "network.loki")
|
||||||
|
.addFormDataPart("Content-Type", contentType)
|
||||||
|
.addFormDataPart("content", UUID.randomUUID().toString(), file)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder().url("$server/files").post(body)
|
||||||
|
return upload(server, request) { json -> // Retrying is handled by AttachmentUploadJob
|
||||||
|
val data = json["data"] as? Map<*, *>
|
||||||
|
if (data == null) {
|
||||||
|
Log.d("Loki", "Couldn't parse attachment from: $json.")
|
||||||
|
throw Error.ParsingFailed
|
||||||
|
}
|
||||||
|
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
|
||||||
|
val url = data["url"] as? String
|
||||||
|
if (id == null || url == null || url.isEmpty()) {
|
||||||
|
Log.d("Loki", "Couldn't parse upload from: $json.")
|
||||||
|
throw Error.ParsingFailed
|
||||||
|
}
|
||||||
|
UploadResult(id, url, file.transmittedDigest)
|
||||||
|
}.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||||
|
fun uploadProfilePicture(server: String, key: ByteArray, profilePicture: StreamDetails, setLastProfilePictureUpload: () -> Unit): UploadResult {
|
||||||
|
val profilePictureUploadData = ProfileAvatarData(profilePicture.stream, ProfileCipherOutputStream.getCiphertextLength(profilePicture.length), profilePicture.contentType, ProfileCipherOutputStreamFactory(key))
|
||||||
|
val file = DigestingRequestBody(profilePictureUploadData.data, profilePictureUploadData.outputStreamFactory,
|
||||||
|
profilePictureUploadData.contentType, profilePictureUploadData.dataLength, null)
|
||||||
|
val body = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("type", "network.loki")
|
||||||
|
.addFormDataPart("Content-Type", "application/octet-stream")
|
||||||
|
.addFormDataPart("content", UUID.randomUUID().toString(), file)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder().url("$server/files").post(body)
|
||||||
|
return retryIfNeeded(4) {
|
||||||
|
upload(server, request) { json ->
|
||||||
|
val data = json["data"] as? Map<*, *>
|
||||||
|
if (data == null) {
|
||||||
|
Log.d("Loki", "Couldn't parse profile picture from: $json.")
|
||||||
|
throw Error.ParsingFailed
|
||||||
|
}
|
||||||
|
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
|
||||||
|
val url = data["url"] as? String
|
||||||
|
if (id == null || url == null || url.isEmpty()) {
|
||||||
|
Log.d("Loki", "Couldn't parse profile picture from: $json.")
|
||||||
|
throw Error.ParsingFailed
|
||||||
|
}
|
||||||
|
setLastProfilePictureUpload()
|
||||||
|
UploadResult(id, url, file.transmittedDigest)
|
||||||
|
}
|
||||||
|
}.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
|
||||||
|
private fun upload(server: String, request: Request.Builder, parse: (Map<*, *>) -> UploadResult): Promise<UploadResult, Exception> {
|
||||||
|
val promise: Promise<Map<*, *>, Exception>
|
||||||
|
if (server == FileServerAPI.shared.server) {
|
||||||
|
request.addHeader("Authorization", "Bearer loki")
|
||||||
|
// Uploads to the Loki File Server shouldn't include any personally identifiable information, so use a dummy auth token
|
||||||
|
promise = OnionRequestAPI.sendOnionRequest(request.build(), FileServerAPI.shared.server, FileServerAPI.fileServerPublicKey)
|
||||||
|
} else {
|
||||||
|
promise = FileServerAPI.shared.getPublicKeyForOpenGroupServer(server).bind { openGroupServerPublicKey ->
|
||||||
|
getAuthToken(server).bind { token ->
|
||||||
|
request.addHeader("Authorization", "Bearer $token")
|
||||||
|
OnionRequestAPI.sendOnionRequest(request.build(), server, openGroupServerPublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return promise.map { json ->
|
||||||
|
parse(json)
|
||||||
|
}.recover { exception ->
|
||||||
|
if (exception is HTTP.HTTPRequestFailedException) {
|
||||||
|
val statusCode = exception.statusCode
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
|
Configuration.shared.storage.setAuthToken(server, null)
|
||||||
|
}
|
||||||
|
throw NonSuccessfulResponseCodeException("Request returned with status code ${exception.statusCode}.")
|
||||||
|
}
|
||||||
|
throw PushNetworkException(exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Boolean.toInt(): Int { return if (this) 1 else 0 }
|
@ -0,0 +1,85 @@
|
|||||||
|
package org.session.libsession.messaging.utilities
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketMessage
|
||||||
|
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketRequestMessage
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
object MessageWrapper {
|
||||||
|
|
||||||
|
// region Types
|
||||||
|
sealed class Error(val description: String) : Exception() {
|
||||||
|
object FailedToWrapData : Error("Failed to wrap data.")
|
||||||
|
object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.")
|
||||||
|
object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.")
|
||||||
|
object FailedToUnwrapData : Error("Failed to unwrap data.")
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Wrapping
|
||||||
|
/**
|
||||||
|
* Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application.
|
||||||
|
*/
|
||||||
|
fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray {
|
||||||
|
try {
|
||||||
|
val envelope = createEnvelope(type, timestamp, senderPublicKey, content)
|
||||||
|
val webSocketMessage = createWebSocketMessage(envelope)
|
||||||
|
return webSocketMessage.toByteArray()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw if (e is Error) { e } else { Error.FailedToWrapData }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope {
|
||||||
|
try {
|
||||||
|
val builder = Envelope.newBuilder()
|
||||||
|
builder.type = type
|
||||||
|
builder.timestamp = timestamp
|
||||||
|
builder.source = senderPublicKey
|
||||||
|
builder.sourceDevice = 1
|
||||||
|
builder.content = ByteString.copyFrom(content)
|
||||||
|
return builder.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.")
|
||||||
|
throw Error.FailedToWrapMessageInEnvelope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage {
|
||||||
|
try {
|
||||||
|
val requestBuilder = WebSocketRequestMessage.newBuilder()
|
||||||
|
requestBuilder.verb = "PUT"
|
||||||
|
requestBuilder.path = "/api/v1/message"
|
||||||
|
requestBuilder.id = SecureRandom.getInstance("SHA1PRNG").nextLong()
|
||||||
|
requestBuilder.body = envelope.toByteString()
|
||||||
|
val messageBuilder = WebSocketMessage.newBuilder()
|
||||||
|
messageBuilder.request = requestBuilder.build()
|
||||||
|
messageBuilder.type = WebSocketMessage.Type.REQUEST
|
||||||
|
return messageBuilder.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to wrap envelope in web socket message: ${e.message}.")
|
||||||
|
throw Error.FailedToWrapEnvelopeInWebSocketMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Unwrapping
|
||||||
|
/**
|
||||||
|
* `data` shouldn't be base 64 encoded.
|
||||||
|
*/
|
||||||
|
fun unwrap(data: ByteArray): Envelope {
|
||||||
|
try {
|
||||||
|
val webSocketMessage = WebSocketMessage.parseFrom(data)
|
||||||
|
val envelopeAsData = webSocketMessage.request.body
|
||||||
|
return Envelope.parseFrom(envelopeAsData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to unwrap data: ${e.message}.")
|
||||||
|
throw Error.FailedToUnwrapData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
package org.session.libsession.messaging.utilities
|
||||||
|
|
||||||
|
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
|
||||||
|
import com.goterl.lazycode.lazysodium.SodiumAndroid
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.Configuration
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.metadata.SignalProtos
|
||||||
|
import org.session.libsignal.metadata.certificate.InvalidCertificateException
|
||||||
|
import org.session.libsignal.service.api.crypto.UnidentifiedAccess
|
||||||
|
import org.session.libsignal.service.api.crypto.UnidentifiedAccessPair
|
||||||
|
|
||||||
|
object UnidentifiedAccessUtil {
|
||||||
|
private val TAG = UnidentifiedAccessUtil::class.simpleName
|
||||||
|
private val sodium = LazySodiumAndroid(SodiumAndroid())
|
||||||
|
|
||||||
|
fun getAccessFor(recipientPublicKey: String): UnidentifiedAccessPair? {
|
||||||
|
try {
|
||||||
|
val theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipientPublicKey)
|
||||||
|
val ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey()
|
||||||
|
val ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate()
|
||||||
|
|
||||||
|
Log.i(TAG, "Their access key present? " + (theirUnidentifiedAccessKey != null) +
|
||||||
|
" | Our access key present? " + (ourUnidentifiedAccessKey != null) +
|
||||||
|
" | Our certificate present? " + (ourUnidentifiedAccessCertificate != null))
|
||||||
|
|
||||||
|
if (theirUnidentifiedAccessKey != null && ourUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) {
|
||||||
|
return UnidentifiedAccessPair(UnidentifiedAccess(theirUnidentifiedAccessKey, ourUnidentifiedAccessCertificate),
|
||||||
|
UnidentifiedAccess(ourUnidentifiedAccessKey, ourUnidentifiedAccessCertificate))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e: InvalidCertificateException) {
|
||||||
|
Log.w(TAG, e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTargetUnidentifiedAccessKey(recipientPublicKey: String): ByteArray? {
|
||||||
|
val theirProfileKey = Configuration.shared.storage.getProfileKeyForRecipient(recipientPublicKey) ?: return sodium.randomBytesBuf(16)
|
||||||
|
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelfUnidentifiedAccessKey(): ByteArray? {
|
||||||
|
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
|
||||||
|
if (userPublicKey != null) {
|
||||||
|
return sodium.randomBytesBuf(16)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUnidentifiedAccessCertificate(): ByteArray? {
|
||||||
|
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
|
||||||
|
if (userPublicKey != null) {
|
||||||
|
val certificate = SignalProtos.SenderCertificate.newBuilder().setSender(userPublicKey).setSenderDevice(1).build()
|
||||||
|
return certificate.toByteArray()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
import org.session.libsignal.service.loki.utilities.Broadcaster
|
||||||
|
|
||||||
|
class Configuration(val storage: SnodeStorageProtocol, val broadcaster: Broadcaster) {
|
||||||
|
companion object {
|
||||||
|
lateinit var shared: Configuration
|
||||||
|
|
||||||
|
fun configure(storage: SnodeStorageProtocol, broadcaster: Broadcaster) {
|
||||||
|
if (Companion::shared.isInitialized) { return }
|
||||||
|
shared = Configuration(storage, broadcaster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,464 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.all
|
||||||
|
import nl.komponents.kovenant.deferred
|
||||||
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.session.libsession.utilities.AESGCM
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsignal.service.loki.api.*
|
||||||
|
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
|
||||||
|
import org.session.libsignal.service.loki.api.utilities.*
|
||||||
|
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
||||||
|
import org.session.libsession.utilities.getBodyForOnionRequest
|
||||||
|
import org.session.libsession.utilities.getHeadersForOnionRequest
|
||||||
|
import org.session.libsignal.service.loki.utilities.*
|
||||||
|
|
||||||
|
private typealias Path = List<Snode>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||||
|
*/
|
||||||
|
public object OnionRequestAPI {
|
||||||
|
private val pathFailureCount = mutableMapOf<Path, Int>()
|
||||||
|
private val snodeFailureCount = mutableMapOf<Snode, Int>()
|
||||||
|
public var guardSnodes = setOf<Snode>()
|
||||||
|
public var paths: List<Path> // Not a set to ensure we consistently show the same path to the user
|
||||||
|
get() = SnodeAPI.database.getOnionRequestPaths()
|
||||||
|
set(newValue) {
|
||||||
|
if (newValue.isEmpty()) {
|
||||||
|
SnodeAPI.database.clearOnionRequestPaths()
|
||||||
|
} else {
|
||||||
|
SnodeAPI.database.setOnionRequestPaths(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Settings
|
||||||
|
/**
|
||||||
|
* The number of snodes (including the guard snode) in a path.
|
||||||
|
*/
|
||||||
|
private val pathSize = 3
|
||||||
|
/**
|
||||||
|
* The number of times a path can fail before it's replaced.
|
||||||
|
*/
|
||||||
|
private val pathFailureThreshold = 2
|
||||||
|
/**
|
||||||
|
* The number of times a snode can fail before it's replaced.
|
||||||
|
*/
|
||||||
|
private val snodeFailureThreshold = 2
|
||||||
|
/**
|
||||||
|
* The number of paths to maintain.
|
||||||
|
*/
|
||||||
|
public val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of guard snodes required to maintain `targetPathCount` paths.
|
||||||
|
*/
|
||||||
|
private val targetGuardSnodeCount
|
||||||
|
get() = targetPathCount // One per path
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>)
|
||||||
|
: Exception("HTTP request failed at destination with status code $statusCode.")
|
||||||
|
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
|
||||||
|
|
||||||
|
private data class OnionBuildingResult(
|
||||||
|
internal val guardSnode: Snode,
|
||||||
|
internal val finalEncryptionResult: EncryptionResult,
|
||||||
|
internal val destinationSymmetricKey: ByteArray
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed class Destination {
|
||||||
|
class Snode(val snode: org.session.libsession.snode.Snode) : Destination()
|
||||||
|
class Server(val host: String, val target: String, val x25519PublicKey: String) : Destination()
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Private API
|
||||||
|
/**
|
||||||
|
* Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
|
||||||
|
*/
|
||||||
|
private fun testSnode(snode: Snode): Promise<Unit, Exception> {
|
||||||
|
val deferred = deferred<Unit, Exception>()
|
||||||
|
Thread { // No need to block the shared context for this
|
||||||
|
val url = "${snode.address}:${snode.port}/get_stats/v1"
|
||||||
|
try {
|
||||||
|
val json = HTTP.execute(HTTP.Verb.GET, url)
|
||||||
|
val version = json["version"] as? String
|
||||||
|
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@Thread }
|
||||||
|
if (version >= "2.0.7") {
|
||||||
|
deferred.resolve(Unit)
|
||||||
|
} else {
|
||||||
|
val message = "Unsupported snode version: $version."
|
||||||
|
Log.d("Loki", message)
|
||||||
|
deferred.reject(Exception(message))
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not
|
||||||
|
* enough (reliable) snodes are available.
|
||||||
|
*/
|
||||||
|
private fun getGuardSnodes(reusableGuardSnodes: List<Snode>): Promise<Set<Snode>, Exception> {
|
||||||
|
if (guardSnodes.count() >= targetGuardSnodeCount) {
|
||||||
|
return Promise.of(guardSnodes)
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Populating guard snode cache.")
|
||||||
|
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||||
|
var unusedSnodes = SnodeAPI.snodePool.minus(reusableGuardSnodes)
|
||||||
|
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||||
|
if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() }
|
||||||
|
fun getGuardSnode(): Promise<Snode, Exception> {
|
||||||
|
val candidate = unusedSnodes.getRandomElementOrNull()
|
||||||
|
?: return Promise.ofFail(InsufficientSnodesException())
|
||||||
|
unusedSnodes = unusedSnodes.minus(candidate)
|
||||||
|
Log.d("Loki", "Testing guard snode: $candidate.")
|
||||||
|
// Loop until a reliable guard snode is found
|
||||||
|
val deferred = deferred<Snode, Exception>()
|
||||||
|
testSnode(candidate).success {
|
||||||
|
deferred.resolve(candidate)
|
||||||
|
}.fail {
|
||||||
|
getGuardSnode().success {
|
||||||
|
deferred.resolve(candidate)
|
||||||
|
}.fail { exception ->
|
||||||
|
if (exception is InsufficientSnodesException) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() }
|
||||||
|
all(promises).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||||
|
val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet()
|
||||||
|
OnionRequestAPI.guardSnodes = guardSnodesAsSet
|
||||||
|
guardSnodesAsSet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and returns `targetPathCount` paths. The returned promise errors out if not
|
||||||
|
* enough (reliable) snodes are available.
|
||||||
|
*/
|
||||||
|
private fun buildPaths(reusablePaths: List<Path>): Promise<List<Path>, Exception> {
|
||||||
|
Log.d("Loki", "Building onion request paths.")
|
||||||
|
SnodeAPI.broadcaster.broadcast("buildingPaths")
|
||||||
|
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||||
|
val reusableGuardSnodes = reusablePaths.map { it[0] }
|
||||||
|
getGuardSnodes(reusableGuardSnodes).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||||
|
var unusedSnodes = SnodeAPI.snodePool.minus(guardSnodes).minus(reusablePaths.flatten())
|
||||||
|
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||||
|
val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||||
|
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
|
||||||
|
// Don't test path snodes as this would reveal the user's IP to them
|
||||||
|
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
|
||||||
|
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).map {
|
||||||
|
val pathSnode = unusedSnodes.getRandomElement()
|
||||||
|
unusedSnodes = unusedSnodes.minus(pathSnode)
|
||||||
|
pathSnode
|
||||||
|
}
|
||||||
|
Log.d("Loki", "Built new onion request path: $result.")
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}.map { paths ->
|
||||||
|
OnionRequestAPI.paths = paths + reusablePaths
|
||||||
|
SnodeAPI.broadcaster.broadcast("pathsBuilt")
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||||
|
*/
|
||||||
|
private fun getPath(snodeToExclude: Snode?): Promise<Path, Exception> {
|
||||||
|
if (pathSize < 1) { throw Exception("Can't build path of size zero.") }
|
||||||
|
val paths = this.paths
|
||||||
|
val guardSnodes = mutableSetOf<Snode>()
|
||||||
|
if (paths.isNotEmpty()) {
|
||||||
|
guardSnodes.add(paths[0][0])
|
||||||
|
if (paths.count() >= 2) {
|
||||||
|
guardSnodes.add(paths[1][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnionRequestAPI.guardSnodes = guardSnodes
|
||||||
|
fun getPath(paths: List<Path>): Path {
|
||||||
|
if (snodeToExclude != null) {
|
||||||
|
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
|
||||||
|
} else {
|
||||||
|
return paths.getRandomElement()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paths.count() >= targetPathCount) {
|
||||||
|
return Promise.of(getPath(paths))
|
||||||
|
} else if (paths.isNotEmpty()) {
|
||||||
|
if (paths.any { !it.contains(snodeToExclude) }) {
|
||||||
|
buildPaths(paths) // Re-build paths in the background
|
||||||
|
return Promise.of(getPath(paths))
|
||||||
|
} else {
|
||||||
|
return buildPaths(paths).map(SnodeAPI.sharedContext) { newPaths ->
|
||||||
|
getPath(newPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return buildPaths(listOf()).map(SnodeAPI.sharedContext) { newPaths ->
|
||||||
|
getPath(newPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropGuardSnode(snode: Snode) {
|
||||||
|
guardSnodes = guardSnodes.filter { it != snode }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropSnode(snode: Snode) {
|
||||||
|
// We repair the path here because we can do it sync. In the case where we drop a whole
|
||||||
|
// path we leave the re-building up to getPath() because re-building the path in that case
|
||||||
|
// is async.
|
||||||
|
snodeFailureCount[snode] = 0
|
||||||
|
val oldPaths = paths.toMutableList()
|
||||||
|
val pathIndex = oldPaths.indexOfFirst { it.contains(snode) }
|
||||||
|
if (pathIndex == -1) { return }
|
||||||
|
val path = oldPaths[pathIndex].toMutableList()
|
||||||
|
val snodeIndex = path.indexOf(snode)
|
||||||
|
if (snodeIndex == -1) { return }
|
||||||
|
path.removeAt(snodeIndex)
|
||||||
|
val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten())
|
||||||
|
if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() }
|
||||||
|
path.add(unusedSnodes.getRandomElement())
|
||||||
|
// Don't test the new snode as this would reveal the user's IP
|
||||||
|
oldPaths.removeAt(pathIndex)
|
||||||
|
val newPaths = oldPaths + listOf( path )
|
||||||
|
paths = newPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropPath(path: Path) {
|
||||||
|
pathFailureCount[path] = 0
|
||||||
|
val paths = OnionRequestAPI.paths.toMutableList()
|
||||||
|
val pathIndex = paths.indexOf(path)
|
||||||
|
if (pathIndex == -1) { return }
|
||||||
|
paths.removeAt(pathIndex)
|
||||||
|
OnionRequestAPI.paths = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an onion around `payload` and returns the result.
|
||||||
|
*/
|
||||||
|
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise<OnionBuildingResult, Exception> {
|
||||||
|
lateinit var guardSnode: Snode
|
||||||
|
lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination
|
||||||
|
lateinit var encryptionResult: EncryptionResult
|
||||||
|
val snodeToExclude = when (destination) {
|
||||||
|
is Destination.Snode -> destination.snode
|
||||||
|
is Destination.Server -> null
|
||||||
|
}
|
||||||
|
return getPath(snodeToExclude).bind(SnodeAPI.sharedContext) { path ->
|
||||||
|
guardSnode = path.first()
|
||||||
|
// Encrypt in reverse order, i.e. the destination first
|
||||||
|
OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind(SnodeAPI.sharedContext) { r ->
|
||||||
|
destinationSymmetricKey = r.symmetricKey
|
||||||
|
// Recursively encrypt the layers of the onion (again in reverse order)
|
||||||
|
encryptionResult = r
|
||||||
|
@Suppress("NAME_SHADOWING") var path = path
|
||||||
|
var rhs = destination
|
||||||
|
fun addLayer(): Promise<EncryptionResult, Exception> {
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
return Promise.of(encryptionResult)
|
||||||
|
} else {
|
||||||
|
val lhs = Destination.Snode(path.last())
|
||||||
|
path = path.dropLast(1)
|
||||||
|
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind(SnodeAPI.sharedContext) { r ->
|
||||||
|
encryptionResult = r
|
||||||
|
rhs = lhs
|
||||||
|
addLayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addLayer()
|
||||||
|
}
|
||||||
|
}.map(SnodeAPI.sharedContext) { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an onion request to `destination`. Builds new paths as needed.
|
||||||
|
*/
|
||||||
|
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>, isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||||
|
val deferred = deferred<Map<*, *>, Exception>()
|
||||||
|
lateinit var guardSnode: Snode
|
||||||
|
buildOnionForDestination(payload, destination).success { result ->
|
||||||
|
guardSnode = result.guardSnode
|
||||||
|
val url = "${guardSnode.address}:${guardSnode.port}/onion_req/v2"
|
||||||
|
val finalEncryptionResult = result.finalEncryptionResult
|
||||||
|
val onion = finalEncryptionResult.ciphertext
|
||||||
|
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPI.maxFileSize.toDouble()) {
|
||||||
|
Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
|
||||||
|
}
|
||||||
|
@Suppress("NAME_SHADOWING") val parameters = mapOf(
|
||||||
|
"ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||||
|
)
|
||||||
|
val body: ByteArray
|
||||||
|
try {
|
||||||
|
body = OnionRequestEncryption.encode(onion, parameters)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return@success deferred.reject(exception)
|
||||||
|
}
|
||||||
|
val destinationSymmetricKey = result.destinationSymmetricKey
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val json = HTTP.execute(HTTP.Verb.POST, url, body)
|
||||||
|
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@Thread deferred.reject(Exception("Invalid JSON"))
|
||||||
|
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
|
||||||
|
try {
|
||||||
|
val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey)
|
||||||
|
try {
|
||||||
|
@Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java)
|
||||||
|
val statusCode = json["status"] as Int
|
||||||
|
if (statusCode == 406) {
|
||||||
|
@Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." )
|
||||||
|
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||||
|
return@Thread deferred.reject(exception)
|
||||||
|
} else if (json["body"] != null) {
|
||||||
|
@Suppress("NAME_SHADOWING") val body: Map<*, *>
|
||||||
|
if (json["body"] is Map<*, *>) {
|
||||||
|
body = json["body"] as Map<*, *>
|
||||||
|
} else {
|
||||||
|
val bodyAsString = json["body"] as String
|
||||||
|
if (!isJSONRequired) {
|
||||||
|
body = mapOf( "result" to bodyAsString )
|
||||||
|
} else {
|
||||||
|
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (statusCode != 200) {
|
||||||
|
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||||
|
return@Thread deferred.reject(exception)
|
||||||
|
}
|
||||||
|
deferred.resolve(body)
|
||||||
|
} else {
|
||||||
|
if (statusCode != 200) {
|
||||||
|
val exception = HTTPRequestFailedAtDestinationException(statusCode, json)
|
||||||
|
return@Thread deferred.reject(exception)
|
||||||
|
}
|
||||||
|
deferred.resolve(json)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}.fail { exception ->
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
val promise = deferred.promise
|
||||||
|
promise.fail { exception ->
|
||||||
|
val path = paths.firstOrNull { it.contains(guardSnode) }
|
||||||
|
if (exception is HTTP.HTTPRequestFailedException) {
|
||||||
|
fun handleUnspecificError() {
|
||||||
|
if (path == null) { return }
|
||||||
|
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0
|
||||||
|
pathFailureCount += 1
|
||||||
|
if (pathFailureCount >= pathFailureThreshold) {
|
||||||
|
dropGuardSnode(guardSnode)
|
||||||
|
path.forEach { snode ->
|
||||||
|
@Suppress("ThrowableNotThrown")
|
||||||
|
SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw
|
||||||
|
}
|
||||||
|
dropPath(path)
|
||||||
|
} else {
|
||||||
|
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val json = exception.json
|
||||||
|
val message = json?.get("result") as? String
|
||||||
|
val prefix = "Next node not found: "
|
||||||
|
if (message != null && message.startsWith(prefix)) {
|
||||||
|
val ed25519PublicKey = message.substringAfter(prefix)
|
||||||
|
val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey }
|
||||||
|
if (snode != null) {
|
||||||
|
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0
|
||||||
|
snodeFailureCount += 1
|
||||||
|
if (snodeFailureCount >= snodeFailureThreshold) {
|
||||||
|
@Suppress("ThrowableNotThrown")
|
||||||
|
SnodeAPI.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw
|
||||||
|
try {
|
||||||
|
dropSnode(snode)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
handleUnspecificError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleUnspecificError()
|
||||||
|
}
|
||||||
|
} else if (message == "Loki Server error") {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
handleUnspecificError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Internal API
|
||||||
|
/**
|
||||||
|
* Sends an onion request to `snode`. Builds new paths as needed.
|
||||||
|
*/
|
||||||
|
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String): Promise<Map<*, *>, Exception> {
|
||||||
|
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||||
|
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
|
||||||
|
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
|
||||||
|
if (httpRequestFailedException != null) {
|
||||||
|
val error = SnodeAPI.handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
|
||||||
|
if (error != null) { throw error }
|
||||||
|
}
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an onion request to `server`. Builds new paths as needed.
|
||||||
|
*
|
||||||
|
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
|
||||||
|
*/
|
||||||
|
public fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc", isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||||
|
val headers = request.getHeadersForOnionRequest()
|
||||||
|
val url = request.url()
|
||||||
|
val urlAsString = url.toString()
|
||||||
|
val host = url.host()
|
||||||
|
val endpoint = when {
|
||||||
|
server.count() < urlAsString.count() -> urlAsString.substringAfter("$server/")
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val body = request.getBodyForOnionRequest() ?: "null"
|
||||||
|
val payload = mapOf(
|
||||||
|
"body" to body,
|
||||||
|
"endpoint" to endpoint,
|
||||||
|
"method" to request.method(),
|
||||||
|
"headers" to headers
|
||||||
|
)
|
||||||
|
val destination = Destination.Server(host, target, x25519PublicKey)
|
||||||
|
return sendOnionRequest(destination, payload, isJSONRequired).recover { exception ->
|
||||||
|
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.")
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.deferred
|
||||||
|
import org.session.libsignal.service.internal.util.JsonUtil
|
||||||
|
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
||||||
|
import org.session.libsession.utilities.AESGCM
|
||||||
|
import org.session.libsignal.service.loki.utilities.toHexString
|
||||||
|
import java.nio.Buffer
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
object OnionRequestEncryption {
|
||||||
|
|
||||||
|
internal fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray {
|
||||||
|
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
|
||||||
|
val jsonAsData = JsonUtil.toJson(json).toByteArray()
|
||||||
|
val ciphertextSize = ciphertext.size
|
||||||
|
val buffer = ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||||
|
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
buffer.putInt(ciphertextSize)
|
||||||
|
val ciphertextSizeAsData = ByteArray(buffer.capacity())
|
||||||
|
// Casting here avoids an issue where this gets compiled down to incorrect byte code. See
|
||||||
|
// https://github.com/eclipse/jetty.project/issues/3244 for more info
|
||||||
|
(buffer as Buffer).position(0)
|
||||||
|
buffer.get(ciphertextSizeAsData)
|
||||||
|
return ciphertextSizeAsData + ciphertext + jsonAsData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||||
|
*/
|
||||||
|
internal fun encryptPayloadForDestination(payload: Map<*, *>, destination: OnionRequestAPI.Destination): Promise<EncryptionResult, Exception> {
|
||||||
|
val deferred = deferred<EncryptionResult, Exception>()
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
// Wrapping isn't needed for file server or open group onion requests
|
||||||
|
when (destination) {
|
||||||
|
is OnionRequestAPI.Destination.Snode -> {
|
||||||
|
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key
|
||||||
|
val payloadAsData = JsonUtil.toJson(payload).toByteArray()
|
||||||
|
val plaintext = encode(payloadAsData, mapOf( "headers" to "" ))
|
||||||
|
val result = AESGCM.encrypt(plaintext, snodeX25519PublicKey)
|
||||||
|
deferred.resolve(result)
|
||||||
|
}
|
||||||
|
is OnionRequestAPI.Destination.Server -> {
|
||||||
|
val plaintext = JsonUtil.toJson(payload).toByteArray()
|
||||||
|
val result = AESGCM.encrypt(plaintext, destination.x25519PublicKey)
|
||||||
|
deferred.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||||
|
*/
|
||||||
|
internal fun encryptHop(lhs: OnionRequestAPI.Destination, rhs: OnionRequestAPI.Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
|
||||||
|
val deferred = deferred<EncryptionResult, Exception>()
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val payload: MutableMap<String, Any>
|
||||||
|
when (rhs) {
|
||||||
|
is OnionRequestAPI.Destination.Snode -> {
|
||||||
|
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
|
||||||
|
}
|
||||||
|
is OnionRequestAPI.Destination.Server -> {
|
||||||
|
payload = mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST" )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
|
||||||
|
val x25519PublicKey: String
|
||||||
|
when (lhs) {
|
||||||
|
is OnionRequestAPI.Destination.Snode -> {
|
||||||
|
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
|
||||||
|
}
|
||||||
|
is OnionRequestAPI.Destination.Server -> {
|
||||||
|
x25519PublicKey = lhs.x25519PublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val plaintext = encode(previousEncryptionResult.ciphertext, payload)
|
||||||
|
val result = AESGCM.encrypt(plaintext, x25519PublicKey)
|
||||||
|
deferred.resolve(result)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
public class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
|
||||||
|
|
||||||
|
val ip: String get() = address.removePrefix("https://")
|
||||||
|
|
||||||
|
internal enum class Method(val rawValue: String) {
|
||||||
|
/**
|
||||||
|
* Only supported by snode targets.
|
||||||
|
*/
|
||||||
|
GetSwarm("get_snodes_for_pubkey"),
|
||||||
|
/**
|
||||||
|
* Only supported by snode targets.
|
||||||
|
*/
|
||||||
|
GetMessages("retrieve"),
|
||||||
|
SendMessage("store")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class KeySet(val ed25519Key: String, val x25519Key: String)
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return if (other is Snode) {
|
||||||
|
address == other.address && port == other.port
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return address.hashCode() xor port.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String { return "$address:$port" }
|
||||||
|
}
|
@ -0,0 +1,370 @@
|
|||||||
|
@file:Suppress("NAME_SHADOWING")
|
||||||
|
|
||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Kovenant
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.deferred
|
||||||
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import nl.komponents.kovenant.task
|
||||||
|
|
||||||
|
import org.session.libsession.snode.utilities.getRandomElement
|
||||||
|
|
||||||
|
import org.session.libsignal.libsignal.logging.Log
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import org.session.libsignal.service.loki.api.MessageWrapper
|
||||||
|
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||||
|
import org.session.libsignal.service.loki.utilities.createContext
|
||||||
|
import org.session.libsignal.service.loki.utilities.prettifiedDescription
|
||||||
|
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||||
|
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
|
||||||
|
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
object SnodeAPI {
|
||||||
|
val database = Configuration.shared.storage
|
||||||
|
val broadcaster = Configuration.shared.broadcaster
|
||||||
|
val sharedContext = Kovenant.createContext("LokiAPISharedContext")
|
||||||
|
val messageSendingContext = Kovenant.createContext("LokiAPIMessageSendingContext")
|
||||||
|
val messagePollingContext = Kovenant.createContext("LokiAPIMessagePollingContext")
|
||||||
|
|
||||||
|
internal var snodeFailureCount: MutableMap<Snode, Int> = mutableMapOf()
|
||||||
|
internal var snodePool: Set<Snode>
|
||||||
|
get() = database.getSnodePool()
|
||||||
|
set(newValue) { database.setSnodePool(newValue) }
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
private val maxRetryCount = 6
|
||||||
|
private val minimumSnodePoolCount = 64
|
||||||
|
private val minimumSwarmSnodeCount = 2
|
||||||
|
private val seedNodePool: Set<String> = setOf( "https://storage.seed1.loki.network", "https://storage.seed3.loki.network", "https://public.loki.foundation" )
|
||||||
|
internal val snodeFailureThreshold = 4
|
||||||
|
private val targetSwarmSnodeCount = 2
|
||||||
|
|
||||||
|
private val useOnionRequests = true
|
||||||
|
|
||||||
|
internal var powDifficulty = 1
|
||||||
|
|
||||||
|
// Error
|
||||||
|
internal sealed class Error(val description: String) : Exception() {
|
||||||
|
object Generic : Error("An error occurred.")
|
||||||
|
object ClockOutOfSync : Error("The user's clock is out of sync with the service node network.")
|
||||||
|
object RandomSnodePoolUpdatingFailed : Error("Failed to update random service node pool.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal API
|
||||||
|
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String, parameters: Map<String, String>): RawResponsePromise {
|
||||||
|
val url = "${snode.address}:${snode.port}/storage_rpc/v1"
|
||||||
|
if (useOnionRequests) {
|
||||||
|
return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey)
|
||||||
|
} else {
|
||||||
|
val deferred = deferred<Map<*, *>, Exception>()
|
||||||
|
Thread {
|
||||||
|
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||||
|
try {
|
||||||
|
val json = HTTP.execute(HTTP.Verb.POST, url, payload)
|
||||||
|
deferred.resolve(json)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
|
||||||
|
if (httpRequestFailedException != null) {
|
||||||
|
val error = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
|
||||||
|
if (error != null) { return@Thread deferred.reject(exception) }
|
||||||
|
}
|
||||||
|
Log.d("Loki", "Unhandled exception: $exception.")
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getRandomSnode(): Promise<Snode, Exception> {
|
||||||
|
val snodePool = this.snodePool
|
||||||
|
if (snodePool.count() < minimumSnodePoolCount) {
|
||||||
|
val target = seedNodePool.random()
|
||||||
|
val url = "$target/json_rpc"
|
||||||
|
Log.d("Loki", "Populating snode pool using: $target.")
|
||||||
|
val parameters = mapOf(
|
||||||
|
"method" to "get_n_service_nodes",
|
||||||
|
"params" to mapOf(
|
||||||
|
"active_only" to true,
|
||||||
|
"fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val deferred = deferred<Snode, Exception>()
|
||||||
|
deferred<org.session.libsignal.service.loki.api.Snode, Exception>(SnodeAPI.sharedContext)
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true)
|
||||||
|
val intermediate = json["result"] as? Map<*, *>
|
||||||
|
val rawSnodes = intermediate?.get("service_node_states") as? List<*>
|
||||||
|
if (rawSnodes != null) {
|
||||||
|
val snodePool = rawSnodes.mapNotNull { rawSnode ->
|
||||||
|
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||||
|
val address = rawSnodeAsJSON?.get("public_ip") as? String
|
||||||
|
val port = rawSnodeAsJSON?.get("storage_port") as? Int
|
||||||
|
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||||
|
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||||
|
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||||
|
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toMutableSet()
|
||||||
|
Log.d("Loki", "Persisting snode pool to database.")
|
||||||
|
this.snodePool = snodePool
|
||||||
|
try {
|
||||||
|
deferred.resolve(snodePool.getRandomElement())
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.d("Loki", "Got an empty snode pool from: $target.")
|
||||||
|
deferred.reject(SnodeAPI.Error.Generic)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.")
|
||||||
|
deferred.reject(SnodeAPI.Error.Generic)
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
deferred.reject(exception)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
return deferred.promise
|
||||||
|
} else {
|
||||||
|
return Promise.of(snodePool.getRandomElement())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
|
||||||
|
val swarm = database.getSwarm(publicKey)?.toMutableSet()
|
||||||
|
if (swarm != null && swarm.contains(snode)) {
|
||||||
|
swarm.remove(snode)
|
||||||
|
database.setSwarm(publicKey, swarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
|
||||||
|
// SecureRandom() should be cryptographically secure
|
||||||
|
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
fun getTargetSnodes(publicKey: String): Promise<List<Snode>, Exception> {
|
||||||
|
// SecureRandom() should be cryptographically secure
|
||||||
|
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSwarm(publicKey: String): Promise<Set<Snode>, Exception> {
|
||||||
|
val cachedSwarm = database.getSwarm(publicKey)
|
||||||
|
if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
|
||||||
|
val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue
|
||||||
|
cachedSwarmCopy.addAll(cachedSwarm)
|
||||||
|
return task { cachedSwarmCopy }
|
||||||
|
} else {
|
||||||
|
val parameters = mapOf( "pubKey" to publicKey )
|
||||||
|
return getRandomSnode().bind {
|
||||||
|
invoke(Snode.Method.GetSwarm, it, publicKey, parameters)
|
||||||
|
}.map(SnodeAPI.sharedContext) {
|
||||||
|
parseSnodes(it).toSet()
|
||||||
|
}.success {
|
||||||
|
database.setSwarm(publicKey, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRawMessages(snode: Snode, publicKey: String): RawResponsePromise {
|
||||||
|
val lastHashValue = database.getLastMessageHashValue(snode, publicKey) ?: ""
|
||||||
|
val parameters = mapOf( "pubKey" to publicKey, "lastHash" to lastHashValue )
|
||||||
|
return invoke(Snode.Method.GetMessages, snode, publicKey, parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessages(publicKey: String): MessageListPromise {
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
getSingleTargetSnode(publicKey).bind(messagePollingContext) { snode ->
|
||||||
|
getRawMessages(snode, publicKey).map(messagePollingContext) { parseRawMessagesResponse(it, snode, publicKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(message: SnodeMessage): Promise<Set<RawResponsePromise>, Exception> {
|
||||||
|
val destination = message.recipient
|
||||||
|
fun broadcast(event: String) {
|
||||||
|
val dayInMs: Long = 86400000
|
||||||
|
if (message.ttl != dayInMs && message.ttl != 4 * dayInMs) { return }
|
||||||
|
broadcaster.broadcast(event, message.timestamp)
|
||||||
|
}
|
||||||
|
broadcast("calculatingPoW")
|
||||||
|
return retryIfNeeded(maxRetryCount) {
|
||||||
|
getTargetSnodes(destination).map(messageSendingContext) { swarm ->
|
||||||
|
swarm.map { snode ->
|
||||||
|
broadcast("sendingMessage")
|
||||||
|
val parameters = message.toJSON()
|
||||||
|
retryIfNeeded(maxRetryCount) {
|
||||||
|
invoke(Snode.Method.SendMessage, snode, destination, parameters).map(messageSendingContext) { rawResponse ->
|
||||||
|
val json = rawResponse as? Map<*, *>
|
||||||
|
val powDifficulty = json?.get("difficulty") as? Int
|
||||||
|
if (powDifficulty != null) {
|
||||||
|
if (powDifficulty != SnodeAPI.powDifficulty && powDifficulty < 100) {
|
||||||
|
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||||
|
SnodeAPI.powDifficulty = powDifficulty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to update proof of work difficulty from: ${rawResponse.prettifiedDescription()}.")
|
||||||
|
}
|
||||||
|
rawResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing
|
||||||
|
private fun parseSnodes(rawResponse: Any): List<Snode> {
|
||||||
|
val json = rawResponse as? Map<*, *>
|
||||||
|
val rawSnodes = json?.get("snodes") as? List<*>
|
||||||
|
if (rawSnodes != null) {
|
||||||
|
return rawSnodes.mapNotNull { rawSnode ->
|
||||||
|
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||||
|
val address = rawSnodeAsJSON?.get("ip") as? String
|
||||||
|
val portAsString = rawSnodeAsJSON?.get("port") as? String
|
||||||
|
val port = portAsString?.toInt()
|
||||||
|
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||||
|
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||||
|
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||||
|
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.")
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List<Envelope> {
|
||||||
|
val messages = rawResponse["messages"] as? List<*>
|
||||||
|
return if (messages != null) {
|
||||||
|
updateLastMessageHashValueIfPossible(snode, publicKey, messages)
|
||||||
|
val newRawMessages = removeDuplicates(publicKey, messages)
|
||||||
|
parseEnvelopes(newRawMessages)
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>) {
|
||||||
|
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
|
||||||
|
val hashValue = lastMessageAsJSON?.get("hash") as? String
|
||||||
|
val expiration = lastMessageAsJSON?.get("expiration") as? Int
|
||||||
|
if (hashValue != null) {
|
||||||
|
database.setLastMessageHashValue(snode, publicKey, hashValue)
|
||||||
|
} else if (rawMessages.isNotEmpty()) {
|
||||||
|
Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeDuplicates(publicKey: String, rawMessages: List<*>): List<*> {
|
||||||
|
val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey)?.toMutableSet() ?: mutableSetOf()
|
||||||
|
return rawMessages.filter { rawMessage ->
|
||||||
|
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||||
|
val hashValue = rawMessageAsJSON?.get("hash") as? String
|
||||||
|
if (hashValue != null) {
|
||||||
|
val isDuplicate = receivedMessageHashValues.contains(hashValue)
|
||||||
|
receivedMessageHashValues.add(hashValue)
|
||||||
|
database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues)
|
||||||
|
!isDuplicate
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEnvelopes(rawMessages: List<*>): List<Envelope> {
|
||||||
|
return rawMessages.mapNotNull { rawMessage ->
|
||||||
|
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||||
|
val base64EncodedData = rawMessageAsJSON?.get("data") as? String
|
||||||
|
val data = base64EncodedData?.let { Base64.decode(it) }
|
||||||
|
if (data != null) {
|
||||||
|
try {
|
||||||
|
MessageWrapper.unwrap(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Handling
|
||||||
|
internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception? {
|
||||||
|
fun handleBadSnode() {
|
||||||
|
val oldFailureCount = snodeFailureCount[snode] ?: 0
|
||||||
|
val newFailureCount = oldFailureCount + 1
|
||||||
|
snodeFailureCount[snode] = newFailureCount
|
||||||
|
Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.")
|
||||||
|
if (newFailureCount >= snodeFailureThreshold) {
|
||||||
|
Log.d("Loki", "Failure threshold reached for: $snode; dropping it.")
|
||||||
|
if (publicKey != null) {
|
||||||
|
dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||||
|
}
|
||||||
|
snodePool = snodePool.toMutableSet().minus(snode).toSet()
|
||||||
|
Log.d("Loki", "Snode pool count: ${snodePool.count()}.")
|
||||||
|
snodeFailureCount[snode] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (statusCode) {
|
||||||
|
400, 500, 503 -> { // Usually indicates that the snode isn't up to date
|
||||||
|
handleBadSnode()
|
||||||
|
}
|
||||||
|
406 -> {
|
||||||
|
Log.d("Loki", "The user's clock is out of sync with the service node network.")
|
||||||
|
broadcaster.broadcast("clockOutOfSync")
|
||||||
|
return Error.ClockOutOfSync
|
||||||
|
}
|
||||||
|
421 -> {
|
||||||
|
// The snode isn't associated with the given public key anymore
|
||||||
|
if (publicKey != null) {
|
||||||
|
Log.d("Loki", "Invalidating swarm for: $publicKey.")
|
||||||
|
dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Got a 421 without an associated public key.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
432 -> {
|
||||||
|
// The PoW difficulty is too low
|
||||||
|
val powDifficulty = json?.get("difficulty") as? Int
|
||||||
|
if (powDifficulty != null) {
|
||||||
|
if (powDifficulty < 100) {
|
||||||
|
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||||
|
SnodeAPI.powDifficulty = powDifficulty
|
||||||
|
} else {
|
||||||
|
handleBadSnode()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("Loki", "Failed to update proof of work difficulty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
handleBadSnode()
|
||||||
|
Log.d("Loki", "Unhandled response code: ${statusCode}.")
|
||||||
|
return Error.Generic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type Aliases
|
||||||
|
typealias RawResponse = Map<*, *>
|
||||||
|
typealias MessageListPromise = Promise<List<Envelope>, Exception>
|
||||||
|
typealias RawResponsePromise = Promise<RawResponse, Exception>
|
@ -0,0 +1,23 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
data class SnodeMessage(
|
||||||
|
// The hex encoded public key of the recipient.
|
||||||
|
val recipient: String,
|
||||||
|
// The content of the message.
|
||||||
|
val data: String,
|
||||||
|
// The time to live for the message in milliseconds.
|
||||||
|
val ttl: Long,
|
||||||
|
// When the proof of work was calculated.
|
||||||
|
val timestamp: Long,
|
||||||
|
// The base 64 encoded proof of work.
|
||||||
|
val nonce: String
|
||||||
|
) {
|
||||||
|
internal fun toJSON(): Map<String, String> {
|
||||||
|
return mutableMapOf(
|
||||||
|
"pubKey" to recipient,
|
||||||
|
"data" to data,
|
||||||
|
"ttl" to ttl.toString(),
|
||||||
|
"timestamp" to timestamp.toString(),
|
||||||
|
"nonce" to nonce)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package org.session.libsession.snode
|
||||||
|
|
||||||
|
interface SnodeStorageProtocol {
|
||||||
|
fun getSnodePool(): Set<Snode>
|
||||||
|
fun setSnodePool(newValue: Set<Snode>)
|
||||||
|
fun getOnionRequestPaths(): List<List<Snode>>
|
||||||
|
fun clearOnionRequestPaths()
|
||||||
|
fun setOnionRequestPaths(newValue: List<List<Snode>>)
|
||||||
|
fun getSwarm(publicKey: String): Set<Snode>?
|
||||||
|
fun setSwarm(publicKey: String, newValue: Set<Snode>)
|
||||||
|
fun getLastMessageHashValue(snode: Snode, publicKey: String): String?
|
||||||
|
fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String)
|
||||||
|
fun getReceivedMessageHashValues(publicKey: String): Set<String>?
|
||||||
|
fun setReceivedMessageHashValues(publicKey: String, newValue: Set<String>)
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
package org.session.libsession.utilities
|
||||||
|
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.Buffer
|
||||||
|
import org.session.libsignal.service.internal.util.Base64
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
internal fun Request.getHeadersForOnionRequest(): Map<String, Any> {
|
||||||
|
val result = mutableMapOf<String, Any>()
|
||||||
|
val contentType = body()?.contentType()
|
||||||
|
if (contentType != null) {
|
||||||
|
result["content-type"] = contentType.toString()
|
||||||
|
}
|
||||||
|
val headers = headers()
|
||||||
|
for (name in headers.names()) {
|
||||||
|
val value = headers.get(name)
|
||||||
|
if (value != null) {
|
||||||
|
if (value.toLowerCase(Locale.US) == "true" || value.toLowerCase(Locale.US) == "false") {
|
||||||
|
result[name] = value.toBoolean()
|
||||||
|
} else if (value.toIntOrNull() != null) {
|
||||||
|
result[name] = value.toInt()
|
||||||
|
} else {
|
||||||
|
result[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Request.getBodyForOnionRequest(): Any? {
|
||||||
|
try {
|
||||||
|
val copyOfThis = newBuilder().build()
|
||||||
|
val buffer = Buffer()
|
||||||
|
val body = copyOfThis.body() ?: return null
|
||||||
|
body.writeTo(buffer)
|
||||||
|
val bodyAsData = buffer.readByteArray()
|
||||||
|
if (body is MultipartBody) {
|
||||||
|
val base64EncodedBody: String = Base64.encodeBytes(bodyAsData)
|
||||||
|
return mapOf( "fileUpload" to base64EncodedBody )
|
||||||
|
} else {
|
||||||
|
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
|
||||||
|
return bodyAsData?.toString(charset)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package org.session.libsession.snode.utilities
|
||||||
|
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses `SecureRandom` to pick an element from this collection.
|
||||||
|
*/
|
||||||
|
fun <T> Collection<T>.getRandomElementOrNull(): T? {
|
||||||
|
val index = SecureRandom().nextInt(size) // SecureRandom() should be cryptographically secure
|
||||||
|
return elementAtOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses `SecureRandom` to pick an element from this collection.
|
||||||
|
*/
|
||||||
|
fun <T> Collection<T>.getRandomElement(): T {
|
||||||
|
return getRandomElementOrNull()!!
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package org.session.libsession.utilities
|
||||||
|
|
||||||
|
import org.whispersystems.curve25519.Curve25519
|
||||||
|
import org.session.libsignal.libsignal.util.ByteUtil
|
||||||
|
import org.session.libsignal.libsignal.util.Hex
|
||||||
|
import org.session.libsignal.service.internal.util.Util
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
internal object AESGCM {
|
||||||
|
|
||||||
|
internal data class EncryptionResult(
|
||||||
|
internal val ciphertext: ByteArray,
|
||||||
|
internal val symmetricKey: ByteArray,
|
||||||
|
internal val ephemeralPublicKey: ByteArray
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val gcmTagSize = 128
|
||||||
|
internal val ivSize = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync. Don't call from the main thread.
|
||||||
|
*/
|
||||||
|
internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||||
|
val iv = ivAndCiphertext.sliceArray(0 until ivSize)
|
||||||
|
val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count())
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||||
|
return cipher.doFinal(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync. Don't call from the main thread.
|
||||||
|
*/
|
||||||
|
internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||||
|
val iv = Util.getSecretBytes(ivSize)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||||
|
return ByteUtil.combine(iv, cipher.doFinal(plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync. Don't call from the main thread.
|
||||||
|
*/
|
||||||
|
internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult {
|
||||||
|
val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey)
|
||||||
|
val ephemeralKeyPair = Curve25519.getInstance(Curve25519.BEST).generateKeyPair()
|
||||||
|
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, ephemeralKeyPair.privateKey)
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
|
||||||
|
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
|
||||||
|
val ciphertext = encrypt(plaintext, symmetricKey)
|
||||||
|
return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user