From c1f84732adc7ecd91651f7f659531c1e8a2a48e0 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO Date: Wed, 2 Dec 2020 16:38:12 +1100 Subject: [PATCH] move and refactor files from libsignal to libsession --- .../messaging/fileserver/FileServerAPI.kt | 262 ++++++++++ .../messaging/opengroups/OpenGroup.kt | 37 ++ .../messaging/opengroups/OpenGroupAPI.kt | 381 ++++++++++++++ .../messaging/opengroups/OpenGroupInfo.kt | 7 + .../messaging/opengroups/OpenGroupMessage.kt | 242 +++++++++ .../sending_receiving/Notification.kt | 2 - .../notifications/Notification.kt | 2 + .../notifications/PushNotificationAPI.kt | 101 ++++ .../messaging/utilities/DotNetAPI.kt | 268 ++++++++++ .../messaging/utilities/MessageWrapper.kt | 85 ++++ .../utilities/UnidentifiedAccessUtil.java | 121 +++++ .../libsession/snode/OnionRequestAPI.kt | 464 ++++++++++++++++++ .../snode/OnionRequestEncryption.kt | 94 ++++ .../org/session/libsession/snode/Snode.kt | 34 ++ .../org/session/libsession/snode/SnodeAPI.kt | 370 ++++++++++++++ .../session/libsession/snode/SnodeMessage.kt | 23 + .../snode/utilities/OKHTTPUtilities.kt | 49 ++ .../libsession/snode/utilities/Random.kt | 18 + .../session/libsession/utilities/AESGCM.kt | 58 +++ 19 files changed, 2616 insertions(+), 2 deletions(-) create mode 100644 libsession/src/main/java/org/session/libsession/messaging/fileserver/FileServerAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupInfo.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupMessage.kt delete mode 100644 libsession/src/main/java/org/session/libsession/messaging/sending_receiving/Notification.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Notification.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/utilities/UnidentifiedAccessUtil.java create mode 100644 libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/Snode.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/utilities/Random.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/fileserver/FileServerAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/fileserver/FileServerAPI.kt new file mode 100644 index 000000000..4cf728a10 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/fileserver/FileServerAPI.kt @@ -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, Exception>>() + /** + * Deprecated. + */ + private val deviceLinkUpdateInterval = 60 * 1000 + private val lastDeviceLinkUpdate = ConcurrentHashMap() + + 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) : 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, 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, isForcedUpdate: Boolean = false): Promise, 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> + 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> + 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() + 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): Promise { + 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 { + 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 { + 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 { + 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 + } + } + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt new file mode 100644 index 000000000..868bb02fe --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt @@ -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 { + return mapOf( "channel" to channel, "server" to server, "displayName" to displayName, "isDeletable" to isDeletable ) + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt new file mode 100644 index 000000000..0cff14e41 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt @@ -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>> = 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 { + 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, Exception> { + Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.") + val storage = Configuration.shared.storage + val parameters = mutableMapOf( "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> + 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>).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>).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>).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, Exception> { + Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.") + val storage = Configuration.shared.storage + val parameters = mutableMapOf() + 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>).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 { + val deferred = deferred() + 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 { + 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, channel: Long, server: String, isSentByUser: Boolean): Promise, 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, 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 + 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 { + 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> + 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 { + 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 { + 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, server: String): Promise, Exception> { + return getUserProfiles(publicKeys, server, false).map(sharedContext) { json -> + val mapping = mutableMapOf() + 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 { + 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 { + return setProfilePicture(server, Base64.encodeBytes(profileKey), url) + } + + public fun setProfilePicture(server: String, profileKey: String, url: String?): Promise { + 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 +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupInfo.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupInfo.kt new file mode 100644 index 000000000..9cd1f18de --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupInfo.kt @@ -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 +) diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupMessage.kt new file mode 100644 index 000000000..51eb95b2f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupMessage.kt @@ -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, + public val profilePicture: ProfilePicture?, + public val signature: Signature?, + public val serverTimestamp: Long, +) { + + // region Settings + companion object { + fun from(message: VisibleMessage, server: String): OpenGroupMessage? { + val storage = Configuration.shared.storage + val userPublicKey = storage.getUserPublicKey() ?: return null + // Validation + if (!message.isValid) { return null } // Should be valid at this point + // Quote + val quote: OpenGroupMessage.Quote? = { + val quote = message.quote + if (quote != null && quote.isValid) { + val quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey) + OpenGroupMessage.Quote(quote.timestamp, quote.publicKey, quote.text, quotedMessageServerID) + } else { + null + } + }() + // Message + val displayname = storage.getUserDisplayName() ?: "Anonymous" + val body = message.text ?: message.sentTimestamp.toString() // The back-end doesn't accept messages without a body so we use this as a workaround + val result = OpenGroupMessage(null, userPublicKey, displayname, body, message.sentTimestamp!!, OpenGroupAPI.openGroupMessageType, quote, mutableListOf(), null, null, 0) + // Link preview + val linkPreview = message.linkPreview + linkPreview?.let { + if (!linkPreview.isValid) { return@let } + val attachment = linkPreview.getImage() ?: return@let + val openGroupLinkPreview = OpenGroupMessage.Attachment( + OpenGroupMessage.Attachment.Kind.LinkPreview, + server, + attachment.getId(), + attachment.getContentType(), + attachment.getSize(), + attachment.getFileName(), + attachment.getFlags(), + attachment.getWidth(), + attachment.getHeight(), + attachment.getCaption(), + attachment.getUrl(), + linkPreview.getUrl(), + linkPreview.getTitle()) + result.attachments.add(openGroupLinkPreview) + } + // Attachments + val attachments = message.getAttachemnts().forEach { + val attachement = OpenGroupMessage.Attachment( + OpenGroupMessage.Attachment.Kind.Attachment, + server, + it.getId(), + it.getContentType(), + it.getSize(), + it.getFileName(), + it.getFlags(), + it.getWidth(), + it.getHeight(), + it.getCaption(), + it.getUrl(), + linkPreview.getUrl(), + linkPreview.getTitle()) + result.attachments.add(attachement) + } + // Return + return result + } + + private val curve = Curve25519.getInstance(Curve25519.BEST) + private val signatureVersion: Long = 1 + private val attachmentType = "net.app.core.oembed" + } + // endregion + + // region Types + public data class ProfilePicture( + public val profileKey: ByteArray, + public val url: String, + ) + + public data class Quote( + public val quotedMessageTimestamp: Long, + public val quoteePublicKey: String, + public val quotedMessageBody: String, + public val quotedMessageServerID: Long? = null, + ) + + public data class Signature( + public val data: ByteArray, + public val version: Long, + ) + + public data class Attachment( + public val kind: Kind, + public val server: String, + public val serverID: Long, + public val contentType: String, + public val size: Int, + public val fileName: String, + public val flags: Int, + public val width: Int, + public val height: Int, + public val caption: String?, + public val url: String, + /** + Guaranteed to be non-`nil` if `kind` is `LinkPreview`. + */ + public val linkPreviewURL: String?, + /** + Guaranteed to be non-`nil` if `kind` is `LinkPreview`. + */ + public val linkPreviewTitle: String?, + ) { + public val dotNetAPIType = when { + contentType.startsWith("image") -> "photo" + contentType.startsWith("video") -> "video" + contentType.startsWith("audio") -> "audio" + else -> "other" + } + + public enum class Kind(val rawValue: String) { + Attachment("attachment"), LinkPreview("preview") + } + } + // endregion + + // region Initialization + constructor(hexEncodedPublicKey: String, displayName: String, body: String, timestamp: Long, type: String, quote: Quote?, attachments: List) + : 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 { + val value = mutableMapOf("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 +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/Notification.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/Notification.kt deleted file mode 100644 index 72036e203..000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/Notification.kt +++ /dev/null @@ -1,2 +0,0 @@ -package org.session.messaging.sending_receiving - diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Notification.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Notification.kt new file mode 100644 index 000000000..f8ae5a8be --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Notification.kt @@ -0,0 +1,2 @@ +package org.session.libsession.messaging.sending_receiving.notifications + diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt new file mode 100644 index 000000000..80d925a08 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt @@ -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}.") + } + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt new file mode 100644 index 000000000..8f881e8ad --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt @@ -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>() + } + + public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?) + + public fun getAuthToken(server: String): Promise { + 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 { + Log.d("Loki", "Requesting auth token for server: $server.") + val userKeyPair = Configuration.shared.storage.getUserKeyPair() ?: throw Error.Generic + val parameters: Map = 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 { + 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 = mapOf(), isJSONRequired: Boolean = true): Promise, Exception> { + fun execute(token: String?): Promise, 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, server: String, includeAnnotations: Boolean): Promise>, 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> + 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, Exception> { + val annotation = mutableMapOf( "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 { + val promise: Promise, 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 } diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt new file mode 100644 index 000000000..8af6ddf5b --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -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 +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UnidentifiedAccessUtil.java b/libsession/src/main/java/org/session/libsession/messaging/utilities/UnidentifiedAccessUtil.java new file mode 100644 index 000000000..c66a9b495 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UnidentifiedAccessUtil.java @@ -0,0 +1,121 @@ +package org.session.libsession.messaging.utilities; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.session.libsignal.libsignal.util.guava.Optional; +import org.session.libsignal.metadata.SignalProtos; +import org.session.libsignal.metadata.certificate.CertificateValidator; +import org.session.libsignal.metadata.certificate.InvalidCertificateException; +import org.session.libsignal.service.api.crypto.UnidentifiedAccess; +import org.session.libsignal.service.api.crypto.UnidentifiedAccessPair; +import org.session.libsignal.service.api.push.SignalServiceAddress; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +public class UnidentifiedAccessUtil { + + private static final String TAG = UnidentifiedAccessUtil.class.getSimpleName(); + + public static CertificateValidator getCertificateValidator() { + return new CertificateValidator(); + } + + @WorkerThread + public static Optional getAccessFor(@NonNull Context context, + @NonNull Recipient recipient) + { + if (!TextSecurePreferences.isUnidentifiedDeliveryEnabled(context)) { + Log.i(TAG, "Unidentified delivery is disabled. [other]"); + return Optional.absent(); + } + + try { + byte[] theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient); + byte[] ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey(context); + byte[] ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate(context); + + if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) { + ourUnidentifiedAccessKey = Util.getSecretBytes(16); + } + + Log.i(TAG, "Their access key present? " + (theirUnidentifiedAccessKey != null) + + " | Our access key present? " + (ourUnidentifiedAccessKey != null) + + " | Our certificate present? " + (ourUnidentifiedAccessCertificate != null)); + + if (theirUnidentifiedAccessKey != null && + ourUnidentifiedAccessKey != null && + ourUnidentifiedAccessCertificate != null) + { + return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(theirUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate), + new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate))); + } + + return Optional.absent(); + } catch (InvalidCertificateException e) { + Log.w(TAG, e); + return Optional.absent(); + } + } + + public static Optional getAccessForSync(@NonNull Context context) { + if (!TextSecurePreferences.isUnidentifiedDeliveryEnabled(context)) { + Log.i(TAG, "Unidentified delivery is disabled. [self]"); + return Optional.absent(); + } + + try { + byte[] ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey(context); + byte[] ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate(context); + + if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) { + ourUnidentifiedAccessKey = Util.getSecretBytes(16); + } + + if (ourUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) { + return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate), + new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate))); + } + + return Optional.absent(); + } catch (InvalidCertificateException e) { + Log.w(TAG, e); + return Optional.absent(); + } + } + + public static @NonNull byte[] getSelfUnidentifiedAccessKey(@NonNull Context context) { + return UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getProfileKey(context)); + } + + private static @Nullable byte[] getTargetUnidentifiedAccessKey(@NonNull Recipient recipient) { + byte[] theirProfileKey = recipient.resolve().getProfileKey(); + + if (theirProfileKey == null) return Util.getSecretBytes(16); + else return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey); + + } + + private static @Nullable byte[] getUnidentifiedAccessCertificate(Context context) { + String ourNumber = TextSecurePreferences.getLocalNumber(context); + if (ourNumber != null) { + SignalProtos.SenderCertificate certificate = SignalProtos.SenderCertificate.newBuilder() + .setSender(ourNumber) + .setSenderDevice(SignalServiceAddress.DEFAULT_DEVICE_ID) + .build(); + return certificate.toByteArray(); + } + + return null; + } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt new file mode 100644 index 000000000..4d12bde10 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -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 + +/** + * 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() + private val snodeFailureCount = mutableMapOf() + public var guardSnodes = setOf() + public var paths: List // 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 { + val deferred = deferred() + 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): Promise, 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 { + 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() + 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): Promise, 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 { + if (pathSize < 1) { throw Exception("Can't build path of size zero.") } + val paths = this.paths + val guardSnodes = mutableSetOf() + 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 { + 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 { + 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 { + 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, Exception> { + val deferred = deferred, 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, 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, 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 +} diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt new file mode 100644 index 000000000..ed907e5b0 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt @@ -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 { + val deferred = deferred() + 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 { + val deferred = deferred() + Thread { + try { + val payload: MutableMap + 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 + } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/Snode.kt b/libsession/src/main/java/org/session/libsession/snode/Snode.kt new file mode 100644 index 000000000..8fd05c5f0 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/Snode.kt @@ -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" } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt new file mode 100644 index 000000000..0b550936b --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -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 = mutableMapOf() + internal var snodePool: Set + 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 = 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): RawResponsePromise { + val url = "${snode.address}:${snode.port}/storage_rpc/v1" + if (useOnionRequests) { + return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey) + } else { + val deferred = deferred, 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 { + 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() + deferred(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 { + // SecureRandom() should be cryptographically secure + return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() } + } + + // Public API + fun getTargetSnodes(publicKey: String): Promise, Exception> { + // SecureRandom() should be cryptographically secure + return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) } + } + + fun getSwarm(publicKey: String): Promise, Exception> { + val cachedSwarm = database.getSwarm(publicKey) + if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { + val cachedSwarmCopy = mutableSetOf() // 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, 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 { + 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 { + 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 { + 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, Exception> +typealias RawResponsePromise = Promise diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt new file mode 100644 index 000000000..558447c54 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt @@ -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 { + return mutableMapOf( + "pubKey" to recipient, + "data" to data, + "ttl" to ttl.toString(), + "timestamp" to timestamp.toString(), + "nonce" to nonce) + } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt b/libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt new file mode 100644 index 000000000..ba3a5dcb7 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt @@ -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 { + val result = mutableMapOf() + 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 + } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/Random.kt b/libsession/src/main/java/org/session/libsession/snode/utilities/Random.kt new file mode 100644 index 000000000..2ec42cdf5 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/utilities/Random.kt @@ -0,0 +1,18 @@ +package org.session.libsession.snode.utilities + +import java.security.SecureRandom + +/** + * Uses `SecureRandom` to pick an element from this collection. + */ +fun Collection.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 Collection.getRandomElement(): T { + return getRandomElementOrNull()!! +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt new file mode 100644 index 000000000..7d2c616c2 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -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) + } + +} \ No newline at end of file